From 812f25241ae1a72bcc960427287d1e7fe607be74 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Mon, 26 Oct 2026 12:33:51 -0700 Subject: [PATCH 1/4] nvx-rs: define Inherited in host::platform::inherit Inherited names a descriptor (Unix) or handle (Windows) of the caller that a VMM inherits. supervisor::child defined it, so the Linux, Windows, and other platform modules, whose spawns take the allowed descriptors or handles as Inherited values, imported it from crate::host::supervisor. That import made platform depend on supervisor, which depends on platform in turn, and is one of the three back-edges that tie eight of host's children into one cycle. Inherited, the alias Borrowed of what it borrows on each host, and their impls move unchanged into a new crate-visible module, host::platform::inherit, from which the platform modules import it, so that platform no longer references supervisor. Inherited stays public, and supervisor re-exports it, so that its only public path is still nvx::host::supervisor::Inherited, where the sandbox's prepared launches and the CLI keep naming it. supervisor::child, which spawns with it, imports it from its new module. The documentation of Inherited links to File and spawn by their paths, since neither is in scope in the new module. The Linux tests of supervisor::child import the descriptor types that the module imported for the moved impls. Part of #897. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- nvx-rs/src/host/platform/inherit.rs | 96 +++++++++++++++++++++++++++++ nvx-rs/src/host/platform/linux.rs | 2 +- nvx-rs/src/host/platform/mod.rs | 1 + nvx-rs/src/host/platform/other.rs | 2 +- nvx-rs/src/host/platform/windows.rs | 2 +- nvx-rs/src/host/supervisor/child.rs | 94 +--------------------------- nvx-rs/src/host/supervisor/mod.rs | 3 +- 7 files changed, 104 insertions(+), 96 deletions(-) create mode 100644 nvx-rs/src/host/platform/inherit.rs diff --git a/nvx-rs/src/host/platform/inherit.rs b/nvx-rs/src/host/platform/inherit.rs new file mode 100644 index 000000000..dcb9bc60d --- /dev/null +++ b/nvx-rs/src/host/platform/inherit.rs @@ -0,0 +1,96 @@ +//! The descriptors or handles of a caller that a VMM inherits. + +#[cfg(not(any(unix, windows)))] +use std::marker::PhantomData; +#[cfg(unix)] +use std::os::fd::{AsFd, BorrowedFd}; +#[cfg(windows)] +use std::os::windows::io::{AsHandle, BorrowedHandle}; +#[cfg(target_os = "linux")] +use std::path::PathBuf; + +/// The descriptor of the caller that an [`Inherited`] borrows. +#[cfg(unix)] +type Borrowed<'a> = BorrowedFd<'a>; +/// The handle of the caller that an [`Inherited`] borrows. +#[cfg(windows)] +type Borrowed<'a> = BorrowedHandle<'a>; +/// What an [`Inherited`] would borrow on a host without descriptors or handles, which has none. +#[cfg(not(any(unix, windows)))] +#[derive(Debug, Clone, Copy)] +struct Borrowed<'a>(std::convert::Infallible, PhantomData<&'a ()>); + +/// A descriptor (Unix) or handle (Windows) of the caller, such as that of an open +/// [`File`](std::fs::File), that a VMM inherits under the number that it has in the caller, which +/// the caller can therefore name in the VMM's arguments. +/// +/// [`spawn`](crate::host::supervisor::spawn) passes the VMM these and its standard streams, and +/// nothing else of the caller's: +/// +/// - On Linux, the descriptor must be close-on-exec in the caller, as Rust opens its files, and +/// stays so: only the VMM's own process clears the flag, between its fork and its exec, so no +/// process that another thread spawns meanwhile inherits it. A spawn refuses a descriptor that +/// is not close-on-exec, since any process that the caller spawns would inherit it. The VMM +/// opens it again as `/proc/self/fd/N`, which `proc_path` returns. +/// - On Windows, the handle joins the explicit list of handles that the VMM inherits, and is +/// inheritable only while the VMM is created, after which the spawn restores its earlier +/// inheritability. Spawns that allow handles take that window one at a time, so several may +/// allow the same handle. A process that another thread creates in that window without such a +/// list, as `std::process::Command` does, can inherit it too. +/// +/// Other hosts fail a spawn that passes any. +#[derive(Debug, Clone, Copy)] +pub struct Inherited<'a>(Borrowed<'a>); + +impl<'a> Inherited<'a> { + /// Names the descriptor of `object`, such as a [`File`](std::fs::File). + #[cfg(unix)] + pub fn new(object: &'a impl AsFd) -> Self { + Self(object.as_fd()) + } + + /// Names the handle of `object`, such as a [`File`](std::fs::File). + #[cfg(windows)] + pub fn new(object: &'a impl AsHandle) -> Self { + Self(object.as_handle()) + } +} + +#[cfg(target_os = "linux")] +impl Inherited<'_> { + /// Returns the path through which the VMM opens the descriptor again: `/proc/self/fd/N`, + /// where `N` is its number in the caller and in the VMM. + pub fn proc_path(&self) -> PathBuf { + use std::os::fd::AsRawFd; + + PathBuf::from(format!("/proc/self/fd/{}", self.0.as_raw_fd())) + } +} + +#[cfg(unix)] +impl<'a> From> for Inherited<'a> { + fn from(descriptor: BorrowedFd<'a>) -> Self { + Self(descriptor) + } +} + +#[cfg(windows)] +impl<'a> From> for Inherited<'a> { + fn from(handle: BorrowedHandle<'a>) -> Self { + Self(handle) + } +} + +#[cfg(unix)] +impl AsFd for Inherited<'_> { + fn as_fd(&self) -> BorrowedFd<'_> { + self.0 + } +} + +#[cfg(windows)] +impl AsHandle for Inherited<'_> { + fn as_handle(&self) -> BorrowedHandle<'_> { + self.0 + } +} diff --git a/nvx-rs/src/host/platform/linux.rs b/nvx-rs/src/host/platform/linux.rs index 239a3782f..3448a5563 100644 --- a/nvx-rs/src/host/platform/linux.rs +++ b/nvx-rs/src/host/platform/linux.rs @@ -9,7 +9,7 @@ use std::thread; use std::time::{Duration, Instant}; use super::Transport; -use crate::host::supervisor::Inherited; +use super::inherit::Inherited; use crate::host::sys::{self, Errno, PidFd}; use crate::hypervisor::Hypervisor; diff --git a/nvx-rs/src/host/platform/mod.rs b/nvx-rs/src/host/platform/mod.rs index 5523ab0c5..e49ce765a 100644 --- a/nvx-rs/src/host/platform/mod.rs +++ b/nvx-rs/src/host/platform/mod.rs @@ -14,6 +14,7 @@ use std::time::Duration; use crate::error::{Error, Result}; use crate::hypervisor::Hypervisor; +pub(crate) mod inherit; #[cfg(target_os = "linux")] /// Linux implementations of host process, socket, and hypervisor primitives. mod linux; diff --git a/nvx-rs/src/host/platform/other.rs b/nvx-rs/src/host/platform/other.rs index 8d32425a8..f94029f65 100644 --- a/nvx-rs/src/host/platform/other.rs +++ b/nvx-rs/src/host/platform/other.rs @@ -5,7 +5,7 @@ use std::process::Command; use std::time::{Duration, Instant}; use super::Transport; -use crate::host::supervisor::Inherited; +use super::inherit::Inherited; use crate::hypervisor::Hypervisor; #[cfg(all(feature = "cli", unix))] diff --git a/nvx-rs/src/host/platform/windows.rs b/nvx-rs/src/host/platform/windows.rs index d02dc855f..d91244b46 100644 --- a/nvx-rs/src/host/platform/windows.rs +++ b/nvx-rs/src/host/platform/windows.rs @@ -61,7 +61,7 @@ use windows_sys::Win32::System::Threading::{ }; use super::Transport; -use crate::host::supervisor::Inherited; +use super::inherit::Inherited; use crate::hypervisor::Hypervisor; /// Whether this host can run NVX microVMs. diff --git a/nvx-rs/src/host/supervisor/child.rs b/nvx-rs/src/host/supervisor/child.rs index 53d1560b8..627d1d2fe 100644 --- a/nvx-rs/src/host/supervisor/child.rs +++ b/nvx-rs/src/host/supervisor/child.rs @@ -5,12 +5,6 @@ use std::ffi::OsString; use std::fmt; use std::fs::File; use std::io::{self, Write}; -#[cfg(not(any(unix, windows)))] -use std::marker::PhantomData; -#[cfg(unix)] -use std::os::fd::{AsFd, BorrowedFd}; -#[cfg(windows)] -use std::os::windows::io::{AsHandle, BorrowedHandle}; use std::path::PathBuf; #[cfg(not(windows))] use std::process::{Child, Command, Stdio}; @@ -22,96 +16,12 @@ use std::time::Duration; use std::time::Instant; use crate::host::platform; +use crate::host::platform::inherit::Inherited; #[cfg(not(windows))] use crate::host::process::POLL_INTERVAL; use crate::host::process::{BootId, KILL_TIMEOUT, Process, ProcessRef}; use crate::protocol::CAPABILITY_LEN; -/// The descriptor of the caller that an [`Inherited`] borrows. -#[cfg(unix)] -type Borrowed<'a> = BorrowedFd<'a>; -/// The handle of the caller that an [`Inherited`] borrows. -#[cfg(windows)] -type Borrowed<'a> = BorrowedHandle<'a>; -/// What an [`Inherited`] would borrow on a host without descriptors or handles, which has none. -#[cfg(not(any(unix, windows)))] -#[derive(Debug, Clone, Copy)] -struct Borrowed<'a>(std::convert::Infallible, PhantomData<&'a ()>); - -/// A descriptor (Unix) or handle (Windows) of the caller, such as that of an open [`File`], that a -/// VMM inherits under the number that it has in the caller, which the caller can therefore name in -/// the VMM's arguments. -/// -/// [`spawn`] passes the VMM these and its standard streams, and nothing else of the caller's: -/// -/// - On Linux, the descriptor must be close-on-exec in the caller, as Rust opens its files, and -/// stays so: only the VMM's own process clears the flag, between its fork and its exec, so no -/// process that another thread spawns meanwhile inherits it. A spawn refuses a descriptor that -/// is not close-on-exec, since any process that the caller spawns would inherit it. The VMM -/// opens it again as `/proc/self/fd/N`, which `proc_path` returns. -/// - On Windows, the handle joins the explicit list of handles that the VMM inherits, and is -/// inheritable only while the VMM is created, after which the spawn restores its earlier -/// inheritability. Spawns that allow handles take that window one at a time, so several may -/// allow the same handle. A process that another thread creates in that window without such a -/// list, as `std::process::Command` does, can inherit it too. -/// -/// Other hosts fail a spawn that passes any. -#[derive(Debug, Clone, Copy)] -pub struct Inherited<'a>(Borrowed<'a>); - -impl<'a> Inherited<'a> { - /// Names the descriptor of `object`, such as a [`File`]. - #[cfg(unix)] - pub fn new(object: &'a impl AsFd) -> Self { - Self(object.as_fd()) - } - - /// Names the handle of `object`, such as a [`File`]. - #[cfg(windows)] - pub fn new(object: &'a impl AsHandle) -> Self { - Self(object.as_handle()) - } -} - -#[cfg(target_os = "linux")] -impl Inherited<'_> { - /// Returns the path through which the VMM opens the descriptor again: `/proc/self/fd/N`, - /// where `N` is its number in the caller and in the VMM. - pub fn proc_path(&self) -> PathBuf { - use std::os::fd::AsRawFd; - - PathBuf::from(format!("/proc/self/fd/{}", self.0.as_raw_fd())) - } -} - -#[cfg(unix)] -impl<'a> From> for Inherited<'a> { - fn from(descriptor: BorrowedFd<'a>) -> Self { - Self(descriptor) - } -} - -#[cfg(windows)] -impl<'a> From> for Inherited<'a> { - fn from(handle: BorrowedHandle<'a>) -> Self { - Self(handle) - } -} - -#[cfg(unix)] -impl AsFd for Inherited<'_> { - fn as_fd(&self) -> BorrowedFd<'_> { - self.0 - } -} - -#[cfg(windows)] -impl AsHandle for Inherited<'_> { - fn as_handle(&self) -> BorrowedHandle<'_> { - self.0 - } -} - /// How [`spawn`] starts a VMM: its executable, arguments, and working directory, whether it /// leaves the caller's job object on Windows, and the descriptors or handles of the caller that it /// inherits besides its standard streams. @@ -407,7 +317,7 @@ fn status_value(status: std::process::ExitStatus) -> i64 { #[cfg(all(test, target_os = "linux"))] mod tests { use std::fs; - use std::os::fd::{AsRawFd, RawFd}; + use std::os::fd::{AsFd, AsRawFd, BorrowedFd, RawFd}; use std::path::Path; use nix::fcntl::{FcntlArg, FdFlag, fcntl}; diff --git a/nvx-rs/src/host/supervisor/mod.rs b/nvx-rs/src/host/supervisor/mod.rs index b61a00ecf..c56bdf69c 100644 --- a/nvx-rs/src/host/supervisor/mod.rs +++ b/nvx-rs/src/host/supervisor/mod.rs @@ -103,8 +103,9 @@ mod tests; use std::io; use std::time::{Duration, Instant}; -pub use self::child::{Inherited, Launched, VmmCommand, spawn}; +pub use self::child::{Launched, VmmCommand, spawn}; use super::launch::timeout_problem; +pub use super::platform::inherit::Inherited; use super::process::{Process, ProcessRef}; use super::store::{ IdentityCheck, IdentityPart, Launch, LaunchJournal, LockedStore, Recovery, RuntimeIdentity, From 0f1710a510d7243b7d87c00888eeafcaf87a6ec0 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Mon, 26 Oct 2026 12:36:58 -0700 Subject: [PATCH 2/4] nvx-rs: move the spawn of a VMM to a private host::spawn supervisor::child held the detached spawn of a VMM: VmmCommand, spawn, and the Launched child that spawn returns. The store records a launched VMM's identity from its Launched, so store imported super::supervisor::Launched, while supervisor runs its launches through the store's journal and imports store: another back-edge of the cycle between host's children. The spawn itself needs neither of them; it imports only platform, process, and protocol. supervisor/child.rs moves with git mv to host/spawn.rs, a private child of host, and store imports Launched from it, so that store no longer references supervisor. supervisor re-exports VmmCommand, spawn, and Launched as it did, so that their public paths stay nvx::host::supervisor::{VmmCommand, spawn, Launched}. The overview of host names the new private module. The documentation of spawn linked to supervisor's launch as super::launch, which from host/spawn.rs would name the module host::launch instead, without a warning from cargo doc, so it links to crate::host::supervisor::launch. Its links to Launch::record_process were crate paths already, and stay documentation rather than references from spawn to store. The four Linux tests of the spawn move with it, from host::supervisor::child::tests to host::spawn::tests. Part of #897. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- nvx-rs/src/host/mod.rs | 4 +++- nvx-rs/src/host/{supervisor/child.rs => spawn.rs} | 10 +++++----- nvx-rs/src/host/store/mod.rs | 2 +- nvx-rs/src/host/supervisor/mod.rs | 3 +-- 4 files changed, 10 insertions(+), 9 deletions(-) rename nvx-rs/src/host/{supervisor/child.rs => spawn.rs} (97%) diff --git a/nvx-rs/src/host/mod.rs b/nvx-rs/src/host/mod.rs index 96eaa2c22..8202391ac 100644 --- a/nvx-rs/src/host/mod.rs +++ b/nvx-rs/src/host/mod.rs @@ -18,7 +18,8 @@ //! - [`supervisor`] launches a sandbox's VMM through the store's journal, passing it only the //! descriptors or handles that the caller allows, stops it through the caller's graceful step, //! with a bounded forced step on request, and reattaches to it after the orchestrator restarts -//! within the same host boot. +//! within the same host boot. The private `spawn` module, whose items `supervisor` re-exports, +//! starts the VMM detached, with no other descriptor or handle of the caller's. //! - [`session`] speaks NVX's control protocol, `NVXC`, over [`nvxs`], which runs the host's role //! of the NVXS session for it and for any other application protocol, over a platform //! transport. [`platform`] holds the primitives of Linux and Windows hosts. Other hosts compile @@ -61,6 +62,7 @@ pub mod report; pub mod sandbox; pub mod seal; pub mod session; +mod spawn; pub mod store; pub mod supervisor; #[cfg(unix)] diff --git a/nvx-rs/src/host/supervisor/child.rs b/nvx-rs/src/host/spawn.rs similarity index 97% rename from nvx-rs/src/host/supervisor/child.rs rename to nvx-rs/src/host/spawn.rs index 627d1d2fe..2e43cd060 100644 --- a/nvx-rs/src/host/supervisor/child.rs +++ b/nvx-rs/src/host/spawn.rs @@ -73,11 +73,11 @@ impl VmmCommand<'_> { /// /// Record the VMM's identity right after the spawn, with /// [`Launch::record_process`](crate::host::store::Launch::record_process), as -/// [`launch`](super::launch) does. Fails with [`io::ErrorKind::InvalidInput`] on Linux for a -/// descriptor of a standard stream, 0 to 2, or one that is not close-on-exec, and on Windows for -/// a batch file, which Windows would run through `cmd.exe` with arguments that it reads as shell -/// syntax; and with [`io::ErrorKind::Unsupported`] for any descriptor or handle on hosts other -/// than Linux and Windows. +/// [`launch`](crate::host::supervisor::launch) does. Fails with [`io::ErrorKind::InvalidInput`] on +/// Linux for a descriptor of a standard stream, 0 to 2, or one that is not close-on-exec, and on +/// Windows for a batch file, which Windows would run through `cmd.exe` with arguments that it +/// reads as shell syntax; and with [`io::ErrorKind::Unsupported`] for any descriptor or handle on +/// hosts other than Linux and Windows. pub fn spawn( command: &VmmCommand<'_>, capability: &[u8; CAPABILITY_LEN], diff --git a/nvx-rs/src/host/store/mod.rs b/nvx-rs/src/host/store/mod.rs index 601fe0a2d..f6e8b023a 100644 --- a/nvx-rs/src/host/store/mod.rs +++ b/nvx-rs/src/host/store/mod.rs @@ -239,7 +239,7 @@ pub use self::record::{ use super::filesystem::FileIdentity; use super::platform; use super::process::{BootId, Process, ProcessRef}; -use super::supervisor::Launched; +use super::spawn::Launched; use crate::error::{Error, ErrorCode, ErrorKind, Result}; use crate::protocol::CAPABILITY_LEN; diff --git a/nvx-rs/src/host/supervisor/mod.rs b/nvx-rs/src/host/supervisor/mod.rs index c56bdf69c..ce80c7b2a 100644 --- a/nvx-rs/src/host/supervisor/mod.rs +++ b/nvx-rs/src/host/supervisor/mod.rs @@ -96,17 +96,16 @@ //! recovery of a launch that did not record its VMM, the process that recovery verified to be that //! VMM. It never signals a process that reuses a recorded ID. -mod child; #[cfg(test)] mod tests; use std::io; use std::time::{Duration, Instant}; -pub use self::child::{Launched, VmmCommand, spawn}; use super::launch::timeout_problem; pub use super::platform::inherit::Inherited; use super::process::{Process, ProcessRef}; +pub use super::spawn::{Launched, VmmCommand, spawn}; use super::store::{ IdentityCheck, IdentityPart, Launch, LaunchJournal, LockedStore, Recovery, RuntimeIdentity, RuntimeRecord, SandboxStore, From b8dfee4cc1fa282015aeac30a8ac6d08aa6a8bc3 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Mon, 26 Oct 2026 15:46:13 -0700 Subject: [PATCH 3/4] nvx-rs: move the checks of required files to host::artifacts artifacts checks each artifact that it locates with require_artifact, which sandbox defined beside its public require_file, together with the helper that both share, require_existing. So artifacts imported crate::host::sandbox::require_artifact, while the sandbox's distro layers import artifacts::DISTRO_MANIFEST_SUFFIX: the last back-edge of the cycle between host's children. The two helpers move unchanged into artifacts, as pub(crate), so that artifacts no longer references sandbox. Only the documentation of require_existing, which described the check through require_file, now describes it itself. The public sandbox::require_file stays where it is, as it is, and calls artifacts::require_existing. The run and sandbox commands of the CLI import require_artifact from artifacts. sandbox no longer needs host::paths, which require_existing used, so the sandbox's tests, which reached paths through sandbox, import it themselves. With this and the previous two commits, the production references between host's children form no cycle, and the children stack in layers, each above the children that it references: network, paths, report, seal, and sys; platform; filesystem, nvxs, and process; session and spawn; store; artifacts; launch; supervisor; instance and sandbox; and cli. Part of #897. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- nvx-rs/src/host/artifacts.rs | 24 +++++++++++++++++++++++- nvx-rs/src/host/cli/run.rs | 4 ++-- nvx-rs/src/host/cli/sandbox/mod.rs | 4 ++-- nvx-rs/src/host/sandbox/mod.rs | 22 +--------------------- nvx-rs/src/host/sandbox/tests.rs | 1 + 5 files changed, 29 insertions(+), 26 deletions(-) diff --git a/nvx-rs/src/host/artifacts.rs b/nvx-rs/src/host/artifacts.rs index f02d44d7e..d221b72f3 100644 --- a/nvx-rs/src/host/artifacts.rs +++ b/nvx-rs/src/host/artifacts.rs @@ -37,8 +37,8 @@ use serde_json::Value; use crate::contract; use crate::error::{Error, ErrorCode, Result}; +use crate::host::paths; use crate::host::platform; -use crate::host::sandbox::require_artifact; use crate::host::store::{ArtifactIdentity, RuntimeIdentity}; /// Name of the release manifest at the root of a release archive or repository checkout. @@ -873,6 +873,28 @@ fn required(path: PathBuf, description: &str) -> Result { Ok(path) } +/// Checks that an artifact that a launch runs, such as the OpenVMM executable, is a regular +/// file, as [`require_existing`] does, and fails with [`ErrorCode::ArtifactMissing`]. +pub(crate) fn require_artifact(path: &Path, description: &str) -> Result<()> { + require_existing(path, description, ErrorCode::ArtifactMissing) +} + +/// Checks that `path` is a regular file, following links, as `require_file` of `nvx.py` does, +/// and fails with `code` and the message `{description} not found: {path}`, with `path` spelled +/// with its components alone, as [`Path::components`] reads them. +pub(crate) fn require_existing(path: &Path, description: &str, code: ErrorCode) -> Result<()> { + if path.is_file() { + return Ok(()); + } + Err(Error::new( + code, + format!( + "{description} not found: {}", + paths::normalized(path).display() + ), + )) +} + /// The paths of the artifacts of an [`ArtifactDir`], which serialize as a JSON object such as: /// /// ```json diff --git a/nvx-rs/src/host/cli/run.rs b/nvx-rs/src/host/cli/run.rs index f1c10cb8c..f04ba2b4e 100644 --- a/nvx-rs/src/host/cli/run.rs +++ b/nvx-rs/src/host/cli/run.rs @@ -31,7 +31,7 @@ use super::sandbox::{ use super::shell::format_command; use super::{Exit, Result, check_interrupt, output}; use crate::ErrorCode; -use crate::host::artifacts::Guest; +use crate::host::artifacts::{Guest, require_artifact}; use crate::host::sandbox::mount::{ AGGREGATE_MOUNT_TARGET, MountKind, MountOwner, POLICY_KINDS, PolicyKind, aggregate_child_argument, aggregate_child_name, aggregate_command_line_fragment, @@ -39,7 +39,7 @@ use crate::host::sandbox::mount::{ require_mount_owner_supported, }; use crate::host::sandbox::options::validate_run_share_targets; -use crate::host::sandbox::{SANDBOX_COMMAND_LINE_MAX_SIZE, has_kernel_separator, require_artifact}; +use crate::host::sandbox::{SANDBOX_COMMAND_LINE_MAX_SIZE, has_kernel_separator}; /// The usage and help of the parser. pub(super) const SYNTAX: Syntax = Syntax { diff --git a/nvx-rs/src/host/cli/sandbox/mod.rs b/nvx-rs/src/host/cli/sandbox/mod.rs index 2be76949b..2004c047a 100644 --- a/nvx-rs/src/host/cli/sandbox/mod.rs +++ b/nvx-rs/src/host/cli/sandbox/mod.rs @@ -31,7 +31,7 @@ use super::network::{NativeRules, NetworkOptions}; use super::shell::format_command; use super::{Exit, Result, output}; use crate::ErrorCode; -use crate::host::artifacts::{Guest, KERNEL_NAME}; +use crate::host::artifacts::{Guest, KERNEL_NAME, require_artifact}; use crate::host::network::{ Access, EgressRule, EgressRules, HostLoopback, LoopbackForward, NetworkPolicy, NetworkProxy, }; @@ -48,7 +48,7 @@ use crate::host::sandbox::scratch::{CLAIM_RELEASE_WAIT, ScratchBinding}; use crate::host::sandbox::{ DEFAULT_ENTRYPOINT, DEFAULT_HOSTNAME, DEFAULT_WORKLOAD_IDENTITY, PIDS_MAX_LIMIT, PreparedLaunch, SandboxLaunch, SandboxLayer, SandboxNetwork, SandboxPlan, SandboxSettings, - parse_workload_identity, require_artifact, + parse_workload_identity, }; use crate::host::store::RuntimeIdentity; use crate::host::supervisor::Inherited; diff --git a/nvx-rs/src/host/sandbox/mod.rs b/nvx-rs/src/host/sandbox/mod.rs index 19a5ab583..947f97ef5 100644 --- a/nvx-rs/src/host/sandbox/mod.rs +++ b/nvx-rs/src/host/sandbox/mod.rs @@ -66,8 +66,8 @@ pub use self::plan::{ }; pub use self::prepared::PreparedLaunch; use crate::error::{Error, ErrorCode, Result}; +use crate::host::artifacts::require_existing; use crate::host::filesystem::ExportPlan; -use crate::host::paths; use crate::net::is_valid_hostname; use crate::protocol::cmdline; use crate::protocol::root::RootImage; @@ -154,26 +154,6 @@ pub fn require_file(path: &Path, description: &str) -> Result<()> { require_existing(path, description, ErrorCode::Unavailable) } -/// Checks that an artifact that a launch runs, such as the OpenVMM executable, is a regular -/// file, as [`require_file`] does, but fails with [`ErrorCode::ArtifactMissing`]. -pub(crate) fn require_artifact(path: &Path, description: &str) -> Result<()> { - require_existing(path, description, ErrorCode::ArtifactMissing) -} - -/// Checks that `path` is a regular file, as [`require_file`] does, and fails with `code`. -fn require_existing(path: &Path, description: &str, code: ErrorCode) -> Result<()> { - if path.is_file() { - return Ok(()); - } - Err(Error::new( - code, - format!( - "{description} not found: {}", - paths::normalized(path).display() - ), - )) -} - /// Refuses a disk path that OpenVMM's `--microvm-sandbox-block` value could not hold: one with /// a comma or semicolon. The message shows `path` as `shown`. fn reject_disk_path(path: &Path, shown: &str) -> Result<()> { diff --git a/nvx-rs/src/host/sandbox/tests.rs b/nvx-rs/src/host/sandbox/tests.rs index 45021f1ba..399e385f7 100644 --- a/nvx-rs/src/host/sandbox/tests.rs +++ b/nvx-rs/src/host/sandbox/tests.rs @@ -6,6 +6,7 @@ use std::path::PathBuf; use super::*; use crate::ErrorKind; use crate::host::filesystem::{Bind, ExportChild, FileIdentity, Owner}; +use crate::host::paths; use crate::protocol::root::{RootLayout, RootUpper}; const DISTRO: &str = "distro,distro.erofs,11111111-1111-1111-1111-111111111111"; From 0bc0fdc034127d0c85e24bd7dc8b9d2658949c44 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Mon, 26 Oct 2026 12:43:28 -0700 Subject: [PATCH 4/4] nvx-rs: guard the dependency graph between host's children The previous three commits made the production references between host's children acyclic. Nothing kept them so: an import of a higher child into a lower one compiles, and closes a cycle that only a reader of the whole module tree would notice. tests/host_modules.rs reads the crate's sources with the standard library alone, following the module declarations from src/lib.rs, and builds the graph of those references as #896 defines them. A child references another when its production code names a path into the other: a crate::, super::, or self:: path, or one from a name that extern crate self as NAME; gives the crate root, in a use tree, an expression, an attribute, the input of a macro, or a string argument of a serde attribute, such as that of with; or a path through a name that a module outside host's children imports, by name or with a glob, as crate::Artifacts, crate::guest_path, and crate::resolve_guest_path are. Such a name stands for every child that its imports name, under any configuration and through other such names and aliases, and a child that imports such a module references every child that the module's names and modules stand for. The test tokenizes the sources, and resolves each path from the module, inline or not, in which it appears. It skips comments, literals, and test-only code: the items, statements, fields, variants, match arms, and parameters that a cfg(test) attribute marks, and the rest of the module or item whose body an inner one opens. Where it cannot tell the < of type arguments from a comparison, it may count test-only code, but never skips production code. Intra-doc links, such as those of spawn to Launch::record_process, and tests are therefore not references. It expands no macros: their input counts as production code, whatever its cfg attributes, with the paths that its strings hold, and it fails on macro_rules! templates that declare or import, that name or may join a path from a module, a metavariable, or a repetition, or hold one in a string, that expand a token tree, or that complete a serde attribute with a metavariable, all of which resolve where the macro expands. It fails as well on the other constructs that it cannot follow, rather than miss the references that pass through them: #[path] attributes, conditional or not; glob imports of host's children; and imports of the crate root or of host itself, under any name, in host's children. The test fails if the references form a cycle, with the shortest cycle through each reference that lies on one, and the first reference, by file and line, along each of its edges. Otherwise, with --nocapture, it prints the layers in which the children stack, each above the children that it references. Ten tests of synthetic crates pin what counts as a reference, where test-only code ends, the children that imports and aliases stand for, the refusals, and the report of cycles. On the parent of the previous three commits, the test fails with the thirteen cycles that their three back-edges close; here, it passes, and prints the layers that #897 lists, with paths in place of python, which #911 deleted. The test runs in the existing cargo test steps of check-nvx-rs, with no new dependency and no workflow change. The nvx-rs README and doc/ci.md describe it. Fixes #897. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- doc/ci.md | 3 +- nvx-rs/README.md | 16 +- nvx-rs/tests/host_modules.rs | 2414 ++++++++++++++++++++++++++++++++++ 3 files changed, 2427 insertions(+), 6 deletions(-) create mode 100644 nvx-rs/tests/host_modules.rs diff --git a/doc/ci.md b/doc/ci.md index 2e9422104..2d405f322 100644 --- a/doc/ci.md +++ b/doc/ci.md @@ -1593,7 +1593,8 @@ which holds the NVX mechanics that the backend builds on, on the same runners through the [`check-nvx-rs`](../.github/actions/check-nvx-rs/action.yml) action. It runs rustfmt, Clippy with warnings denied for all targets and features and for the library without default features, the unit, protocol -conformance-vector and property, and fake-OpenVMM instance tests, rustdoc with +conformance-vector and property, and fake-OpenVMM instance tests, the test that +keeps the references between the children of `nvx::host` acyclic, rustdoc with warnings denied, the minimum Rust version check, and the macOS cross-check. It also validates bundled artifact staging with a fixture package. Both actions set `NVX_BUNDLE=skip` for `--all-features` builds, so that `bundled` stages nothing diff --git a/nvx-rs/README.md b/nvx-rs/README.md index 3e4dcb697..8c97b5bef 100644 --- a/nvx-rs/README.md +++ b/nvx-rs/README.md @@ -54,10 +54,11 @@ nvx-rs/ │ │ the typed calls through which it runs the time ABI's steps and the reseed │ └── bin/ nvx-rs, nvx-fake-openvmm, and the guest binaries ├── tests/ fake-OpenVMM, CLI, NVXS session, process-supervision, checked-handoff, store, -│ and guest-binary tests, the CLI's scenario tests and their harness, in cli/, -│ the store's record fixtures, the hand-written sandbox configurations of every -│ format, and the cases of the export and root boot tokens, in fixtures/, and -│ the conformance vectors of the wire formats, in vectors/ +│ host-modules, and guest-binary tests, the CLI's scenario tests and their +│ harness, in cli/, the store's record fixtures, the hand-written sandbox +│ configurations of every format, and the cases of the export and root boot +│ tokens, in fixtures/, and the conformance vectors of the wire formats, in +│ vectors/ ├── build.rs bundled-release staging └── artifacts.json the NVX release that the `bundled` feature stages ``` @@ -1117,7 +1118,12 @@ The process-supervision test crashes orchestrator processes at each step of a launch through the supervisor, reattaches to the VMM stand-ins that they leave behind and stops them, and checks that a VMM inherits only its standard streams and the descriptors or handles that it is allowed while another thread spawns -processes. The tests of `nvx::exec` drive a runner and a peer through a +processes. The host-modules test reads the crate's sources, and fails if the +production code of the children of `nvx::host`, outside comments and +`cfg(test)` code, references them in a cycle; it lists each cycle, with a +reference along each of its edges. So the children stack in layers, each above +the children that it references, which the test prints with `--nocapture`. +The tests of `nvx::exec` drive a runner and a peer through a simulated workload, transport, and sink on a clock of their own, and, on Linux, the tests of `nvx::guest::exec::binding` drive real programs through them, two at once in one loop, one of them with a stalled sink; run as root, one of them diff --git a/nvx-rs/tests/host_modules.rs b/nvx-rs/tests/host_modules.rs new file mode 100644 index 000000000..990be915c --- /dev/null +++ b/nvx-rs/tests/host_modules.rs @@ -0,0 +1,2414 @@ +//! Keeps the production dependency graph between the children of `nvx::host` acyclic. +//! +//! The children of `host` are the modules that `src/host/mod.rs` declares outside test-only code. +//! A child references another when its production code names a path into the other: a `crate::`, +//! `super::`, or `self::` path, or one from a name that `extern crate self as NAME;` gives the +//! crate root, in a `use` tree, an expression, an attribute, a macro's input, or a string argument +//! of a `serde` attribute, such as that of `with`, or a path through a name that a module outside +//! host's children imports, by name or with a glob, such as `crate::Artifacts`. Such a name +//! stands for everything that its imports name, under any configuration and through other such +//! names, and a child that imports one of those modules, by name or with a glob, references every +//! child that the module's names and modules stand for. Comments, literals outside the input of +//! macros, and code that only `cfg(test)` builds are not references, so intra-doc links and tests +//! may name any child. The references must form no cycle, so that the children stack in layers, +//! each of which references only those below it. The test prints the layers, lowest first. +//! +//! The test reads the crate's sources with the standard library alone, following the module +//! declarations from `src/lib.rs`. It tokenizes them, skips comments and test-only code, and +//! resolves each path from the module, inline or not, in which it appears. Test-only code is the +//! item, statement, field, variant, match arm, or parameter that a `cfg(test)` attribute marks, and +//! the rest of the module or item whose body an inner one opens. Where the test cannot tell where +//! test-only code ends, as when a `<` may open type arguments or compare, it may count test-only +//! code, but it never skips production code. It expands no macros: the input of a macro counts as +//! production code, whatever its `cfg` attributes, whose strings may hold paths and whose +//! declarations the test leaves to the macro where it cannot read them, and the template of a +//! `macro_rules!` names only what is in scope where it expands, which the test reads there. The +//! test fails on what it cannot follow, rather than miss the references that pass through it: +//! `#[path]` attributes, conditional or not; macro templates that declare or import, that name or +//! may join a path from a module, a metavariable, or a repetition, or hold one in a string, that +//! expand a token tree, or that complete a `serde` attribute with a metavariable; glob imports of +//! host's children; and imports of the crate root or of `host` itself, under any name, in host's +//! children. + +use std::collections::{BTreeMap, BTreeSet, VecDeque}; +use std::fmt::Write as _; +use std::fs; +use std::ops::Range; +use std::panic; +use std::path::Path; +use std::slice; + +/// The children of `host` that each child references, with the first reference to each, by file +/// and line. +type Graph = BTreeMap>; + +/// The modules that each module declares, by the module's path from the crate root. +type Modules = BTreeMap, BTreeSet>; + +/// The questions whether a module provides a name, by the module's path and the name, that a +/// resolution is answering, so that a cycle of glob imports, which Rust allows, ends. +type Questions = BTreeSet<(Vec, String)>; + +/// The keywords that may precede a path but never continue one with `::`, unlike `crate`, +/// `self`, `Self`, and `super`, which are segments of paths. +const KEYWORDS: &[&str] = &[ + "abstract", "as", "async", "await", "become", "box", "break", "const", "continue", "do", "dyn", + "else", "enum", "extern", "false", "final", "fn", "for", "gen", "if", "impl", "in", "let", + "loop", "macro", "match", "mod", "move", "mut", "override", "priv", "pub", "ref", "return", + "static", "struct", "trait", "true", "try", "type", "typeof", "unsafe", "unsized", "use", + "virtual", "where", "while", "yield", +]; + +/// What the modules outside host's children, such as the crate root and `host`, import, by paths +/// from the crate root. +#[derive(Default)] +struct Imports { + /// The paths that each name that they bind may stand for, under any configuration. + names: BTreeMap, BTreeSet>>, + /// The modules whose names each of them imports with a glob. + globs: BTreeMap, BTreeSet>>, +} + +impl Imports { + /// Adds to `paths` every path that `path` may stand for, under any configuration: itself, and, + /// for each name that it goes through, what the name stands for, and the same name in each + /// module that the name's module imports with a glob, if that module provides it. + fn expand( + &self, + modules: &Modules, + path: &[String], + paths: &mut BTreeSet>, + asking: &mut Questions, + ) { + // Rust refuses imports that cycle, so the paths of a crate that builds stay short. + assert!( + path.len() <= 1024, + "the test follows paths of up to 1024 segments, which {} exceeds", + path.join("::") + ); + if !paths.insert(path.to_vec()) { + return; + } + for length in 1..=path.len() { + for target in self.names.get(&path[..length]).into_iter().flatten() { + let alternative = [target.as_slice(), &path[length..]].concat(); + self.expand(modules, &alternative, paths, asking); + } + let name = &path[length - 1]; + for source in self.globs.get(&path[..length - 1]).into_iter().flatten() { + if self.provides(modules, source, name, asking) { + let alternative = [source.as_slice(), &path[length - 1..]].concat(); + self.expand(modules, &alternative, paths, asking); + } + } + } + } + + /// Returns whether `module`, or what it may stand for, declares or binds `name`, directly or + /// through the modules that it imports with a glob. A question that `asking` holds already is + /// part of a cycle, which provides nothing that its other answers do not. + fn provides( + &self, + modules: &Modules, + module: &[String], + name: &String, + asking: &mut Questions, + ) -> bool { + let question = (module.to_vec(), name.clone()); + if !asking.insert(question.clone()) { + return false; + } + let mut aliases = BTreeSet::new(); + self.expand(modules, module, &mut aliases, asking); + let provided = aliases.iter().any(|module| { + modules + .get(module) + .is_some_and(|declared| declared.contains(name)) + || self + .names + .contains_key(&[module.as_slice(), slice::from_ref(name)].concat()) + || self + .globs + .get(module) + .into_iter() + .flatten() + .any(|source| self.provides(modules, source, name, asking)) + }); + asking.remove(&question); + provided + } +} + +/// The paths that a `use` tree names: the segments of each, the name that it binds, `*` for a +/// glob, and the line of its last token. +type UsePaths = Vec<(Vec, Option, usize)>; + +/// A token of a source file. Comments and whitespace are not tokens. +struct Token { + /// The identifier or keyword, without the `r#` of a raw identifier, or the punctuation, with + /// `::` as one token. Literals and lifetimes, whose text no scan needs, have none. + text: String, + /// Whether the token is an identifier or keyword. + ident: bool, + /// Whether the token is a raw identifier, which is never a keyword. + raw: bool, + /// The text between the quotes of a string literal, which an attribute may take as a path. + string: Option, + /// The line on which the token starts, from 1. + line: usize, +} + +/// A cursor over the characters of a source file, which counts its lines. +struct Lexer { + /// The characters of the source. + chars: Vec, + /// The position of the next character. + at: usize, + /// The line of the next character, from 1. + line: usize, +} + +impl Lexer { + /// Returns the character `offset` positions past the next one, if there is one. + fn peek(&self, offset: usize) -> Option { + self.chars.get(self.at + offset).copied() + } + + /// Moves past `count` characters. + fn bump(&mut self, count: usize) { + for _ in 0..count { + if self.peek(0) == Some('\n') { + self.line += 1; + } + self.at += 1; + } + } + + /// Moves past the characters of an identifier, a keyword, a number, or a lifetime's name, and + /// returns them. + fn word(&mut self) -> String { + let start = self.at; + while self + .peek(0) + .is_some_and(|character| character == '_' || character.is_alphanumeric()) + { + self.bump(1); + } + self.chars[start..self.at].iter().collect() + } + + /// Moves past the literal that `quote` delimits, from its opening quote, and returns its + /// value: the text between its quotes, with its escapes decoded. + fn quoted(&mut self, quote: char) -> String { + self.bump(1); + let mut value = String::new(); + while let Some(character) = self.peek(0) { + self.bump(1); + if character == quote { + return value; + } + if character != '\\' { + value.push(character); + continue; + } + let Some(escape) = self.peek(0) else { + break; + }; + self.bump(1); + match escape { + 'n' => value.push('\n'), + 'r' => value.push('\r'), + 't' => value.push('\t'), + '0' => value.push('\0'), + 'x' => { + let digits: String = (0..2).filter_map(|offset| self.peek(offset)).collect(); + self.bump(digits.len()); + value.extend( + u32::from_str_radix(&digits, 16) + .ok() + .and_then(char::from_u32), + ); + } + 'u' => { + let mut digits = String::new(); + if self.peek(0) == Some('{') { + self.bump(1); + while let Some(digit) = self.peek(0) { + self.bump(1); + match digit { + '}' => break, + '_' => {} + _ => digits.push(digit), + } + } + } + value.extend( + u32::from_str_radix(&digits, 16) + .ok() + .and_then(char::from_u32), + ); + } + // A line continuation skips the line break and the whitespace after it. + '\n' | '\r' => { + while self.peek(0).is_some_and(char::is_whitespace) { + self.bump(1); + } + } + other => value.push(other), + } + } + value + } + + /// Moves past a raw string literal, from the `#` signs or the quote after its prefix, and + /// returns its value: the text between its quotes. + fn raw(&mut self) -> String { + let mut hashes = 0; + while self.peek(hashes) == Some('#') { + hashes += 1; + } + self.bump(hashes + 1); + let start = self.at; + while let Some(character) = self.peek(0) { + if character == '"' && (1..=hashes).all(|offset| self.peek(offset) == Some('#')) { + let value = self.chars[start..self.at].iter().collect(); + self.bump(hashes + 1); + return value; + } + self.bump(1); + } + self.chars[start.min(self.chars.len())..].iter().collect() + } + + /// Moves past a block comment, which may nest others, from its `/*`. + fn block_comment(&mut self) { + let mut depth = 0; + while let Some(character) = self.peek(0) { + match (character, self.peek(1)) { + ('/', Some('*')) => { + depth += 1; + self.bump(2); + } + ('*', Some('/')) => { + depth -= 1; + self.bump(2); + if depth == 0 { + return; + } + } + _ => self.bump(1), + } + } + } +} + +/// Splits `source` into tokens, without its comments, doc comments included. +fn tokenize(source: &str) -> Vec { + let mut lexer = Lexer { + chars: source.chars().collect(), + at: 0, + line: 1, + }; + let mut tokens = Vec::new(); + while let Some(character) = lexer.peek(0) { + let line = lexer.line; + let next = lexer.peek(1); + let mut raw = false; + let mut string = None; + let (text, ident) = match character { + _ if character.is_whitespace() => { + lexer.bump(1); + continue; + } + '/' if next == Some('/') => { + while lexer.peek(0).is_some_and(|character| character != '\n') { + lexer.bump(1); + } + continue; + } + '/' if next == Some('*') => { + lexer.block_comment(); + continue; + } + '"' => { + string = Some(lexer.quoted('"')); + (String::new(), false) + } + // Unlike a lifetime or a label, a character literal closes after one character or + // escape. + '\'' if next == Some('\\') || lexer.peek(2) == Some('\'') => { + lexer.quoted('\''); + (String::new(), false) + } + '\'' => { + lexer.bump(1); + lexer.word(); + (String::new(), false) + } + ':' if next == Some(':') => { + lexer.bump(2); + ("::".to_owned(), false) + } + _ if character.is_ascii_digit() => { + lexer.word(); + while lexer.peek(0) == Some('.') + && lexer.peek(1).is_some_and(|digit| digit.is_ascii_digit()) + { + lexer.bump(1); + lexer.word(); + } + (String::new(), false) + } + _ if character == '_' || character.is_alphabetic() => { + let word = lexer.word(); + match (word.as_str(), lexer.peek(0), lexer.peek(1)) { + ("r", Some('#'), Some(start)) if start == '_' || start.is_alphabetic() => { + lexer.bump(1); + raw = true; + (lexer.word(), true) + } + ("r", Some('#' | '"'), _) => { + string = Some(lexer.raw()); + (String::new(), false) + } + ("br" | "cr", Some('#' | '"'), _) => { + lexer.raw(); + (String::new(), false) + } + ("b" | "c", Some('"'), _) => { + lexer.quoted('"'); + (String::new(), false) + } + ("b", Some('\''), _) => { + lexer.quoted('\''); + (String::new(), false) + } + _ => (word, true), + } + } + _ => { + lexer.bump(1); + (character.to_string(), false) + } + }; + tokens.push(Token { + text, + ident, + raw, + string, + line, + }); + } + tokens +} + +/// A source file of the crate, as tokens. +struct Source { + /// The file's path from the crate's directory, with `/` separators. + path: String, + /// The file's tokens. + tokens: Vec, +} + +impl Source { + /// Returns the text of the token at `at`, or nothing past the last token. + fn text(&self, at: usize) -> &str { + self.tokens.get(at).map_or("", |token| &token.text) + } + + /// Returns the text of the token at `at` where a keyword or punctuation may stand: nothing for + /// a raw identifier, which is never a keyword, or past the last token. + fn keyword(&self, at: usize) -> &str { + self.tokens + .get(at) + .filter(|token| !token.raw) + .map_or("", |token| &token.text) + } + + /// Returns whether the token at `at` is an identifier or keyword. + fn is_ident(&self, at: usize) -> bool { + self.tokens.get(at).is_some_and(|token| token.ident) + } + + /// Returns the line of the token at `at`, or of the last token past it. + fn line(&self, at: usize) -> usize { + self.tokens + .get(at) + .or(self.tokens.last()) + .map_or(1, |token| token.line) + } + + /// Fails the test at the token at `at`, with `problem`. + fn fail(&self, at: usize, problem: &str) -> ! { + panic!("{}:{}: {problem}", self.path, self.line(at)) + } + + /// Returns the position of the bracket that closes the one at `open`. + fn closing(&self, open: usize) -> usize { + let mut depth = 0; + for (at, token) in self.tokens.iter().enumerate().skip(open) { + match token.text.as_str() { + "(" | "[" | "{" => depth += 1, + ")" | "]" | "}" if depth == 1 => return at, + ")" | "]" | "}" => depth -= 1, + _ => {} + } + } + self.fail(open, "this bracket is not closed") + } + + /// Returns whether the attribute whose name is at `start` is `name`, or a `cfg_attr` that + /// applies one under some configuration. + fn applies(&self, start: usize, name: &str) -> bool { + match self.text(start) { + text if text == name => true, + "cfg_attr" if self.text(start + 1) == "(" => { + // The attributes that it applies follow the commas at the top level of its + // arguments, the first of which is its predicate. + let close = self.closing(start + 1); + let mut depth = 0usize; + (start + 2..close).any(|position| { + let comma = depth == 0 && self.text(position) == ","; + match self.text(position) { + "(" | "[" | "{" => depth += 1, + ")" | "]" | "}" => depth -= 1, + _ => {} + } + comma && self.applies(position + 1, name) + }) + } + _ => false, + } + } + + /// Returns the position of the innermost bracket between `from` and `at` that opens before + /// `at` and is still open there, if any. + fn enclosing(&self, from: usize, at: usize) -> Option { + let mut depth = 0usize; + for position in (from..at).rev() { + match self.text(position) { + ")" | "]" | "}" => depth += 1, + "(" | "[" | "{" if depth == 0 => return Some(position), + "(" | "[" | "{" => depth -= 1, + _ => {} + } + } + None + } + + /// Returns the first keyword that tells the kind of the group that opens at `open`, among + /// those of items and `match`, in its header: the tokens before it at its level, back to the + /// `;` or brace before them, or to `from`. + fn header_keyword(&self, from: usize, open: usize) -> Option<&str> { + let mut depth = 0usize; + let mut first = None; + for position in (from..open).rev() { + match self.keyword(position) { + ")" | "]" => depth += 1, + "(" | "[" if depth > 0 => depth -= 1, + ";" | "(" | "[" | "{" | "}" if depth == 0 => break, + keyword @ ("enum" | "extern" | "fn" | "impl" | "match" | "mod" | "struct" + | "trait" | "union") + if depth == 0 => + { + first = Some(keyword); + } + _ => {} + } + } + first + } + + /// Returns whether the construct that starts at `start`, past `from`, is a field of a struct, + /// a union, or a variant, or a parameter of a function. Its `<` all open type arguments, and + /// none of its keywords starts an item: `fn`, `unsafe`, and `extern` start the type of a + /// function pointer there. + fn in_fields(&self, from: usize, start: usize) -> bool { + let Some(open) = self.enclosing(from, start) else { + return false; + }; + match (self.text(open), self.header_keyword(from, open)) { + ("{", Some("struct" | "union")) | ("(", Some("fn" | "struct")) => true, + // The fields of a variant. + ("{" | "(", None) => self.enclosing(from, open).is_some_and(|parent| { + self.text(parent) == "{" && self.header_keyword(from, parent) == Some("enum") + }), + _ => false, + } + } + + /// Returns the position of the bracket that opens the group that closes at `close`, past + /// `from`, if any. + fn opening(&self, from: usize, close: usize) -> Option { + let mut depth = 0usize; + for position in (from..=close).rev() { + match self.text(position) { + ")" | "]" | "}" => depth += 1, + "(" | "[" | "{" if depth == 1 => return Some(position), + "(" | "[" | "{" => depth = depth.saturating_sub(1), + _ => {} + } + } + None + } + + /// Returns the keyword or punctuation before the attributes that precede `start`, past + /// `from`, or nothing if they start at `from`. + fn before_attributes(&self, from: usize, start: usize) -> &str { + let mut at = start; + while at > from && self.text(at - 1) == "]" { + let Some(open) = self.opening(from, at - 1) else { + break; + }; + match open.checked_sub(1).map(|position| self.text(position)) { + Some("#") if open > from => at = open - 1, + Some("!") if open > from + 1 && self.text(open - 2) == "#" => at = open - 2, + _ => break, + } + } + if at > from { self.keyword(at - 1) } else { "" } + } + + /// Returns whether an outer attribute that precedes the item whose keyword is at `keyword`, + /// past `from` and the item's visibility, is a `cfg` attribute, or a `cfg_attr` that applies + /// one, which may leave the item out. + fn conditional(&self, from: usize, keyword: usize) -> bool { + let mut at = keyword; + if at > from + 1 + && self.text(at - 1) == ")" + && let Some(open) = self.opening(from, at - 1) + && open > from + && self.keyword(open - 1) == "pub" + { + at = open - 1; + } else if at > from && self.keyword(at - 1) == "pub" { + at -= 1; + } + while at > from + 1 && self.text(at - 1) == "]" { + let Some(open) = self.opening(from, at - 1) else { + break; + }; + if open <= from || self.text(open - 1) != "#" { + break; + } + if self.applies(open + 1, "cfg") { + return true; + } + at = open - 1; + } + false + } + + /// Returns whether the inner attributes at the start of the module body or file that starts + /// at `start` include a `cfg` attribute, or a `cfg_attr` that applies one, which may leave + /// the module out. + fn inner_conditional(&self, start: usize) -> bool { + let mut at = start; + while self.text(at) == "#" && self.text(at + 1) == "!" && self.text(at + 2) == "[" { + if self.applies(at + 3, "cfg") { + return true; + } + at = self.closing(at + 2) + 1; + } + false + } + + /// Returns the end of the construct that starts at `start`, past `from` and before `end`: past + /// the `;` or the closing brace of an item or statement, past the `,` of a field, variant, or + /// match arm, or at the bracket that closes the group around it. + /// + /// A generic or closure parameter, after a `<`, `|`, or `,`, also ends at a `>` or `|` that + /// may close its list. So does any construct after a `,` in a list other than fields, + /// variants, and match arms, such as the fields of a struct expression. + /// + /// The `,` and braces between the `<` and `>` of type or generic arguments do not end a + /// construct: those of a `::<`, and, in items, fields, and parameters, those of every `<`. A + /// construct whose `<` never close ends past its first `,`, if any. + fn construct_end(&self, from: usize, start: usize, end: usize) -> usize { + let mut head = start; + if self.keyword(head) == "pub" { + head += 1; + if self.keyword(head) == "(" { + head = self.closing(head) + 1; + } + } + let before = self.before_attributes(from, start); + let fields = self.in_fields(from, start); + let listed = fields + || self.enclosing(from, start).is_some_and(|open| { + self.text(open) == "{" + && matches!(self.header_keyword(from, open), Some("enum" | "match")) + }); + let parameter = matches!(before, "<" | "|") || before == "," && !listed; + let item = !fields + && !parameter + && before != "(" + && matches!( + self.keyword(head), + "async" + | "const" + | "enum" + | "extern" + | "fn" + | "impl" + | "let" + | "macro_rules" + | "mod" + | "static" + | "struct" + | "trait" + | "type" + | "union" + | "unsafe" + | "use" + ); + // These end at their semicolon, whatever blocks their expressions hold. + let semicolon = item + && (matches!(self.keyword(head), "let" | "static" | "type" | "use") + || self.keyword(head) == "const" + && !matches!( + self.keyword(head + 1), + "async" | "extern" | "fn" | "unsafe" | "{" + )); + let types = item || fields || matches!(before, "<" | "|") || self.keyword(start) == "pub"; + let mut depth = 0; + let mut angles = 0usize; + let mut first_comma = None; + for at in start..end { + let previous = self.keyword(at.saturating_sub(1)); + match self.keyword(at) { + "(" | "[" | "{" => depth += 1, + ")" | "]" | "}" if depth == 0 => { + return if angles == 0 { + at + } else { + first_comma.unwrap_or(at) + }; + } + "}" if depth == 1 + && angles == 0 + && !semicolon + && !matches!(self.keyword(at + 1), "else" | "." | "?") => + { + return at + 1; + } + ")" | "]" | "}" => depth -= 1, + ";" if depth == 0 => return at + 1, + "|" if parameter && depth == 0 && angles == 0 => return at, + ">" if parameter && depth == 0 && angles == 0 && !matches!(previous, "-" | "=") => { + return at; + } + "<" if depth == 0 && (types || previous == "::") => angles += 1, + // The `>` of `->` and `=>` closes no `<`. + ">" if depth == 0 && angles > 0 && !matches!(previous, "-" | "=") => angles -= 1, + "," if depth == 0 && !item => { + if angles == 0 { + return at + 1; + } + first_comma.get_or_insert(at + 1); + } + _ => {} + } + } + if angles == 0 { + end + } else { + first_comma.unwrap_or(end) + } + } + + /// Returns the first segment of the `crate::`, `super::`, `self::`, or `$crate::` path that + /// starts at `at`, if one does, and the position of the `::` after it. + fn path_start(&self, at: usize) -> Option<(&str, usize)> { + let (first, after) = match self.keyword(at) { + "$" if self.keyword(at + 1) == "crate" => ("crate", at + 2), + first @ ("crate" | "super" | "self") => (first, at + 1), + _ => return None, + }; + (self.text(after) == "::").then_some((first, after)) + } + + /// Returns the segments of the path from a module of the crate that starts at `at`, if one + /// does, and the position past it: a `crate::`, `super::`, `self::`, or `$crate::` path, or a + /// path from a name of `prelude`, which the extern prelude gives the crate root, with `::` + /// first if `::` starts it. + fn path_at(&self, at: usize, prelude: &BTreeSet) -> Option<(Vec, usize)> { + if let Some((first, next)) = self.path_start(at) { + return Some(self.segments(first, next)); + } + if !prelude.contains(self.text(at)) || self.text(at + 1) != "::" { + return None; + } + // After a segment, the name continues a path. A `>` may close a type or compare, so the + // name after it may start one. + let global = at >= 1 && self.text(at - 1) == "::"; + if global && at >= 2 && self.is_ident(at - 2) && !KEYWORDS.contains(&self.keyword(at - 2)) { + return None; + } + let (mut segments, next) = self.segments(self.text(at), at + 1); + if global { + segments.insert(0, "::".to_owned()); + } + Some((segments, next)) + } + + /// Returns the segments of the path whose first segment is `first`, and whose others follow + /// the `::` at `next`, and the position past them. + fn segments(&self, first: &str, mut next: usize) -> (Vec, usize) { + let mut segments = vec![first.to_owned()]; + while self.text(next) == "::" && self.is_ident(next + 1) { + segments.push(self.text(next + 1).to_owned()); + next += 2; + } + (segments, next) + } + + /// Returns the paths from modules of the crate in `text`, such as a path that a string literal + /// holds, as [`Source::path_at`] finds them with `prelude`. + fn paths_in(text: &str, prelude: &BTreeSet) -> Vec> { + let source = Self { + path: String::new(), + tokens: tokenize(text), + }; + let mut paths = Vec::new(); + let mut at = 0; + while at < source.tokens.len() { + match source.path_at(at, prelude) { + Some((segments, next)) => { + paths.push(segments); + at = next; + } + None => at += 1, + } + } + paths + } + + /// Returns the position of the first construct of the transcribers of a `macro_rules!`, whose + /// rules are `self.tokens[range]`, that names what only the macro's expansions resolve, if + /// any: a `use`, `mod`, or `extern crate` declaration; a path from a module of the crate, as + /// [`Source::path_at`] finds them with `prelude`, or a string that holds one, as the argument + /// of a `serde` attribute would; a metavariable or repetition that may join a path from the + /// input, before `::` or a segment and `::`, after `::` or the start of a path from a module; + /// a metavariable of a token tree, which may hold any of these; or a `serde` attribute that a + /// metavariable completes, whose string the input holds. Matchers only match the input of the + /// macro. + fn unfollowed_in_template( + &self, + range: Range, + prelude: &BTreeSet, + ) -> Option { + let mut at = range.start; + while at < range.end { + // A rule is a matcher, `=>`, and a transcriber, each a delimited group. + if !matches!(self.text(at), "(" | "[" | "{") { + at += 1; + continue; + } + let matcher = self.closing(at); + let open = matcher + 3; + if (self.text(matcher + 1), self.text(matcher + 2)) != ("=", ">") + || !matches!(self.text(open), "(" | "[" | "{") + { + at = matcher + 1; + continue; + } + let close = self.closing(open); + // The metavariables that match a token tree, such as `crate` or `::`. + let trees: BTreeSet<&str> = (at + 1..matcher) + .filter(|&position| { + self.text(position) == "$" + && self.is_ident(position + 1) + && self.text(position + 2) == ":" + && self.text(position + 3) == "tt" + }) + .map(|position| self.text(position + 1)) + .collect(); + let unfollowed = (open + 1..close).find(|&position| { + let declares = match self.keyword(position) { + "use" => self.text(position + 1) != "<", + "mod" => true, + "extern" => self.keyword(position + 1) == "crate", + _ => false, + }; + // The token after a metavariable, or after a repetition and its separator, and + // whether that separator, or the first token of the repetition, is `::`. Either + // may expand to `::` or to the start of a path, such as `crate ::`. + let expansion = match (self.text(position), self.text(position + 1)) { + ("$", "(") => { + let group = self.closing(position + 1); + let separator = !matches!(self.text(group + 1), "*" | "+" | "?"); + let joined = (separator && self.text(group + 1) == "::") + || self.text(position + 2) == "::"; + Some((group + 2 + usize::from(separator), joined)) + } + ("$", _) if self.is_ident(position + 1) => Some((position + 2, false)), + _ => None, + }; + let joins = expansion.is_some_and(|(after, joined)| { + joined + || self.text(after) == "::" + || (self.is_ident(after) + && !KEYWORDS.contains(&self.keyword(after)) + && self.text(after + 1) == "::") + }); + let tree = self.text(position) == "$" && trees.contains(self.text(position + 1)); + let start = (matches!(self.keyword(position), "crate" | "self" | "super") + || prelude.contains(self.text(position))) + && self.text(position + 1) == "$"; + let string = self.tokens[position] + .string + .as_deref() + .is_some_and(|text| !Self::paths_in(text, prelude).is_empty()); + let serde = self.text(position) == "#" && { + let open = position + 1 + usize::from(self.text(position + 1) == "!"); + self.text(open) == "[" && { + let end = self.closing(open); + let has = + |text: &str| (open + 1..end).any(|index| self.text(index) == text); + has("serde") && has("$") + } + }; + declares + || joins + || tree + || start + || string + || serde + || self.path_at(position, prelude).is_some() + }); + if unfollowed.is_some() { + return unfollowed; + } + at = close + 1; + } + None + } + + /// Expands the `use` tree at `*at`, under `prefix`, into `paths`, moves `*at` past it, and + /// returns whether it could read the tree, which the input of a macro need not be. + fn use_tree(&self, at: &mut usize, prefix: &[String], paths: &mut UsePaths) -> bool { + let mut path = prefix.to_vec(); + if self.text(*at) == "::" { + // A path from the extern prelude, which names this crate only by a name that + // `extern crate self` gives it. + path.push("::".to_owned()); + *at += 1; + } + loop { + let line = self.line(*at); + match self.text(*at) { + "{" => { + *at += 1; + while self.text(*at) != "}" { + if !self.use_tree(at, &path, paths) { + return false; + } + if self.text(*at) == "," { + *at += 1; + } + } + *at += 1; + return true; + } + "*" => { + *at += 1; + paths.push((path, Some("*".to_owned()), line)); + return true; + } + "$" if self.keyword(*at + 1) == "crate" => { + path.push("crate".to_owned()); + *at += 2; + } + // `self` at the start of a group names the group's prefix. + "self" + if self.keyword(*at) == "self" + && !prefix.is_empty() + && path.len() == prefix.len() => + { + *at += 1; + } + segment if self.is_ident(*at) => { + path.push(segment.to_owned()); + *at += 1; + } + _ => return false, + } + if self.text(*at) == "::" { + *at += 1; + continue; + } + let binding = if self.keyword(*at) == "as" { + *at += 2; + Some(self.text(*at - 1).to_owned()).filter(|alias| alias != "_") + } else { + path.last().cloned() + }; + paths.push((path, binding, line)); + return true; + } + } +} + +/// Reads the `cfg` predicate at `*at`, moves `*at` past it, and returns whether it holds only in +/// test builds: `test`, `all` of predicates of which one does, or `any` of predicates that all do. +fn needs_test(source: &Source, at: &mut usize) -> bool { + let name = source.text(*at); + *at += 1; + match source.text(*at) { + "=" => { + // An option with a value, such as `feature = "cli"`. + *at += 2; + false + } + "(" => { + *at += 1; + let mut operands = Vec::new(); + while !matches!(source.text(*at), ")" | "") { + operands.push(needs_test(source, at)); + if source.text(*at) == "," { + *at += 1; + } + } + *at += 1; + match name { + "all" => operands.contains(&true), + "any" => !operands.is_empty() && !operands.contains(&false), + _ => false, + } + } + _ => name == "test", + } +} + +/// A path that production code names. +#[derive(Clone)] +struct Reference { + /// The module in which the path appears, from the crate root, such as `["host", "store"]`. + module: Vec, + /// The file in which the path appears, from the crate's directory. + file: String, + /// The line of the path's last segment. + line: usize, + /// The path's segments, as its code or its `use` tree writes them, with `::` first if `::` + /// starts it. + segments: Vec, + /// The name that a `use` declaration binds to the path, `*` for a glob, if any. + binding: Option, + /// Whether the declaration binds the name only in a block, such as the body of a function, + /// rather than in `module`. + local: bool, +} + +/// What a scan found in the production code of the crate's modules. +#[derive(Default)] +struct Scan { + /// The modules that each module declares, by the module's path from the crate root. + modules: Modules, + /// The names that each module declares or imports by name in every configuration, outside + /// the input of a macro, which shadow those of the extern prelude. + unconditional: Modules, + /// The number of definitions of each module that a declaration defines: alternatives under + /// different configurations, if more than one. + definitions: BTreeMap, usize>, + /// The names that `extern crate self as NAME;` at the crate root gives the crate root in the + /// extern prelude, where every module can name it. + extern_prelude: BTreeSet, + /// The paths that the code names. + references: Vec, +} + +impl Scan { + /// Scans the crate's production code from its root file, `root`, from the crate's directory, + /// and the files of the modules that it declares, reading each with `read`. + fn walk(&mut self, read: &dyn Fn(&str) -> Option, root: &str) { + let text = read(root).unwrap_or_else(|| panic!("cannot read {root}")); + let source = Source { + path: root.to_owned(), + tokens: tokenize(&text), + }; + // Every module may name the crate root by the names that `extern crate self as NAME;` + // gives it at the crate root, wherever the root's code declares them. Counting one that + // an inline module declares, for that module alone, only adds references. + for at in 0..source.tokens.len() { + if (0..4) + .map(|offset| source.keyword(at + offset)) + .eq(["extern", "crate", "self", "as"]) + && source.is_ident(at + 4) + && source.text(at + 4) != "_" + { + self.extern_prelude.insert(source.text(at + 4).to_owned()); + } + } + let dir = root + .rsplit_once('/') + .map_or_else(String::new, |(dir, _)| format!("{dir}/")); + let mut files = vec![(source, Vec::new(), dir)]; + while let Some((source, module, dir)) = files.pop() { + let mut declared = Vec::new(); + let range = 0..source.tokens.len(); + self.code(&source, range, &module, &dir, false, &mut declared); + for (child, dir, input) in declared { + let name = child.join("::"); + let base = &dir[..dir.len() - 1]; + let mut found = [format!("{base}.rs"), format!("{dir}mod.rs")] + .into_iter() + .filter_map(|path| read(&path).map(|text| (path, text))); + let Some((path, text)) = found.next() else { + // A macro need not declare the modules that its input does. + assert!(input, "module {name} has no file in {base}"); + continue; + }; + assert!(found.next().is_none(), "module {name} has two files"); + let source = Source { + path, + tokens: tokenize(&text), + }; + // A file whose inner attributes may leave its module out declares it only in + // some configurations. + if source.inner_conditional(0) + && let Some((last, parent)) = child.split_last() + && let Some(names) = self.unconditional.get_mut(parent) + { + names.remove(last); + } + files.push((source, child, dir)); + } + } + } + + /// Scans the tokens `range` of `source`, the code of `module`, whose child modules' files are + /// in the directory `dir`, and adds the modules that it declares in files to `declared`, with + /// their own directories and whether a macro's input declares them. In the input of a macro, + /// as `input` tells, which need not be Rust, paths count, but declarations that the test + /// cannot read are left to the macro. + fn code( + &mut self, + source: &Source, + range: Range, + module: &[String], + dir: &str, + input: bool, + declared: &mut Vec<(Vec, String, bool)>, + ) { + let mut at = range.start; + // Whether the attributes since the last construct make the next one test-only. + let mut test_only = false; + // The paths that the outer attributes since the last construct name, which count unless + // the next construct is test-only. + let mut pending = Vec::new(); + while at < range.end { + let text = source.keyword(at); + if text == "#" && matches!(source.text(at + 1), "[" | "!") { + let inner = source.text(at + 1) == "!"; + let open = at + 1 + usize::from(inner); + let close = source.closing(open); + let mut next = close + 1; + // An attribute names the paths in its tokens, and a `serde` attribute those in + // its string arguments too, such as that of `with`, unless only test builds apply + // it. A macro may keep or drop the attributes of its input as it pleases, so there + // no `cfg` marks anything test-only. + let serde = (open + 1..close) + .any(|position| source.is_ident(position) && source.text(position) == "serde"); + let test_attribute = !input + && source.text(open + 1) == "cfg_attr" + && source.text(open + 2) == "(" + && needs_test(source, &mut (open + 3)); + let mut found = Vec::new(); + let mut position = open + 1; + while !test_attribute && position < close { + let mut after = position + 1; + let paths = if let Some((segments, end)) = + source.path_at(position, &self.extern_prelude) + { + after = end; + vec![segments] + } else if serde && let Some(text) = source.tokens[position].string.as_deref() { + Source::paths_in(text, &self.extern_prelude) + } else { + Vec::new() + }; + for segments in paths { + found.push(Reference { + module: module.to_vec(), + file: source.path.clone(), + line: source.line(after - 1), + segments, + binding: None, + local: false, + }); + } + position = after; + } + // An inner attribute is the enclosing construct's, which is production code here. + if inner { + self.references.append(&mut found); + } else { + pending.append(&mut found); + } + match (source.text(open + 1), source.text(open + 2)) { + _ if source.applies(open + 1, "path") => { + source.fail(at, "the test does not follow #[path] attributes"); + } + ("cfg", "(") if !input && needs_test(source, &mut (open + 3)) => { + if !inner { + test_only = true; + } else if let Some(group) = source.enclosing(range.start, at) { + // The rest of the item whose body the attribute opens is test-only. + next = source.closing(group); + } else { + // The rest of the module is test-only. + return; + } + } + _ => {} + } + at = next; + } else if test_only { + pending.clear(); + at = source.construct_end(range.start, at, range.end); + test_only = false; + } else if !pending.is_empty() { + self.references.append(&mut pending); + } else if text == "macro_rules" + && source.text(at + 1) == "!" + && source.is_ident(at + 2) + && matches!(source.text(at + 3), "{" | "(" | "[") + { + // A template names nothing that the expansions of the macro do not name where + // they expand, unless it declares, imports, or names a path from a module. + let close = source.closing(at + 3); + let unfollowed = source.unfollowed_in_template(at + 4..close, &self.extern_prelude); + if let Some(position) = unfollowed { + source.fail( + position, + "the test does not follow macro templates that declare, import, or name \ + paths from a module or from their input, which resolve where the macro \ + expands, possibly in host's children", + ); + } + at = close + 1; + } else if text == "!" + && at > range.start + && source.is_ident(at - 1) + && !KEYWORDS.contains(&source.keyword(at - 1)) + && matches!(source.text(at + 1), "(" | "[" | "{") + { + let close = source.closing(at + 1); + self.code(source, at + 2..close, module, dir, true, declared); + at = close + 1; + } else if text == "mod" + && source.is_ident(at + 1) + && matches!(source.text(at + 2), ";" | "{") + { + let name = source.text(at + 1).to_owned(); + let mut child = module.to_vec(); + child.push(name.clone()); + *self.definitions.entry(child.clone()).or_default() += 1; + let child_dir = format!("{dir}{name}/"); + let inline = source.text(at + 2) == "{"; + if !input + && source.enclosing(range.start, at).is_none() + && !source.conditional(range.start, at) + && (!inline || !source.inner_conditional(at + 3)) + { + self.unconditional + .entry(module.to_vec()) + .or_default() + .insert(name.clone()); + } + self.modules + .entry(module.to_vec()) + .or_default() + .insert(name); + if source.text(at + 2) == ";" { + declared.push((child, child_dir, input)); + at += 3; + } else { + let close = source.closing(at + 2); + self.code(source, at + 3..close, &child, &child_dir, input, declared); + at = close + 1; + } + } else if text == "use" && source.text(at + 1) != "<" { + let mut paths = Vec::new(); + let mut next = at + 1; + if source.use_tree(&mut next, &[], &mut paths) && source.text(next) == ";" { + // In a block, such as the body of a function, the declaration binds its + // names for the block alone. The input of a macro may expand anywhere. + let local = !input && source.enclosing(range.start, at).is_some(); + let unconditional = !input && !local && !source.conditional(range.start, at); + for (segments, binding, line) in paths { + if unconditional + && let Some(name) = binding.as_ref().filter(|name| *name != "*") + { + self.unconditional + .entry(module.to_vec()) + .or_default() + .insert(name.clone()); + } + self.references.push(Reference { + module: module.to_vec(), + file: source.path.clone(), + line, + segments, + binding, + local, + }); + } + at = next + 1; + } else if input { + at += 1; + } else { + source.fail(at, "the test cannot read this use declaration"); + } + } else if text == "extern" + && source.keyword(at + 1) == "crate" + && source.is_ident(at + 2) + { + // `extern crate self as NAME;` names the crate root, and another crate's name + // names none of this crate's modules. + let name = source.text(at + 2); + let segments = if source.keyword(at + 2) == "self" { + vec!["crate".to_owned()] + } else { + vec!["::".to_owned(), name.to_owned()] + }; + let (binding, next) = if source.keyword(at + 3) == "as" { + (source.text(at + 4), at + 5) + } else { + (name, at + 3) + }; + self.references.push(Reference { + module: module.to_vec(), + file: source.path.clone(), + line: source.line(at), + segments, + binding: Some(binding.to_owned()).filter(|binding| binding != "_"), + local: !input && source.enclosing(range.start, at).is_some(), + }); + at = next; + } else if input && let Some(text) = source.tokens[at].string.as_deref() { + // A macro may put a string of its input where a path is read, such as in a + // `serde` attribute that it completes. + for segments in Source::paths_in(text, &self.extern_prelude) { + self.references.push(Reference { + module: module.to_vec(), + file: source.path.clone(), + line: source.line(at), + segments, + binding: None, + local: false, + }); + } + at += 1; + } else if let Some((segments, next)) = source.path_at(at, &self.extern_prelude) { + self.references.push(Reference { + module: module.to_vec(), + file: source.path.clone(), + line: source.line(next - 1), + segments, + binding: None, + local: false, + }); + at = next; + } else { + at += 1; + } + } + } + + /// Returns the paths from the crate root that the path `segments`, which the code of `module` + /// names, may stand for: one from `crate`; from `self` or `super`; from a module that `module` + /// declares, or a name that it binds or may import with a glob, as `imports` records them; + /// and from a name that the extern prelude gives the crate root, which `::` may precede, + /// unless `module`, which a single definition defines, declares or imports that name in + /// every configuration. + fn resolve( + &self, + module: &[String], + segments: &[String], + imports: &Imports, + ) -> Vec> { + let Some((first, rest)) = segments.split_first() else { + return Vec::new(); + }; + let mut paths = Vec::new(); + match first.as_str() { + "crate" => paths.push(rest.to_vec()), + "self" | "super" => { + // `self` names the module, and each `super` after it, or at the start, the parent + // of the module before. + let mut path = module.to_vec(); + let mut rest = if first == "self" { rest } else { segments }; + while let Some((segment, after)) = rest.split_first() + && segment == "super" + { + if path.pop().is_none() { + return Vec::new(); + } + rest = after; + } + path.extend_from_slice(rest); + paths.push(path); + } + "::" => { + if let Some((name, rest)) = rest.split_first() + && self.extern_prelude.contains(name) + { + paths.push(rest.to_vec()); + } + } + _ => { + if self + .modules + .get(module) + .is_some_and(|declared| declared.contains(first)) + || imports + .names + .contains_key(&[module, slice::from_ref(first)].concat()) + || imports.globs.contains_key(module) + { + paths.push([module, segments].concat()); + } + // Alternative definitions of `module` may each shadow the name or not. + let shadowed = self.definitions.get(module).is_none_or(|&count| count == 1) + && self + .unconditional + .get(module) + .is_some_and(|declared| declared.contains(first)); + if self.extern_prelude.contains(first) && !shadowed { + paths.push(rest.to_vec()); + } + } + } + paths + } +} + +/// Returns the production graph between host's children in the crate whose files `read` reads, +/// by their paths from the crate's directory. +fn graph(read: &dyn Fn(&str) -> Option) -> Graph { + let mut scan = Scan::default(); + scan.walk(read, "src/lib.rs"); + let host = ["host".to_owned()]; + let children = scan + .modules + .get(host.as_slice()) + .cloned() + .unwrap_or_default(); + // The child whose code a module is, if it is one of host's children or inside one. + let child_of = |module: &[String]| match module { + [first, child, ..] if *first == host[0] && children.contains(child) => Some(child.clone()), + _ => None, + }; + // The children that a path from the crate root may name: directly, or through what the + // modules outside host's children import. + let targets = |path: &[String], imports: &Imports| -> BTreeSet { + let mut paths = BTreeSet::new(); + imports.expand(&scan.modules, path, &mut paths, &mut Questions::new()); + paths.iter().filter_map(|path| child_of(path)).collect() + }; + // The children that the names under the module that a path from the crate root may name may + // stand for: what an import of the module, or a glob of it, can bring into a child. They are + // the children that the modules under it are or are in, the names that the modules outside + // host's children import there, and those under the modules that those names, their glob + // imports, and their modules stand for in turn. + let names_under = |path: &[String], imports: &Imports| -> BTreeSet { + let mut found = BTreeSet::new(); + let mut pending = vec![path.to_vec()]; + let mut seen = BTreeSet::new(); + while let Some(path) = pending.pop() { + let mut modules = BTreeSet::new(); + imports.expand(&scan.modules, &path, &mut modules, &mut Questions::new()); + for module in modules { + if !seen.insert(module.clone()) { + continue; + } + for name in imports + .names + .keys() + .filter(|name| name.len() > module.len() && name.starts_with(&module)) + { + found.extend(targets(name, imports)); + pending.push(name.clone()); + } + pending.extend(imports.globs.get(&module).into_iter().flatten().cloned()); + for declared in scan.modules.get(&module).into_iter().flatten() { + let declared = [module.as_slice(), slice::from_ref(declared)].concat(); + found.extend(child_of(&declared)); + pending.push(declared); + } + } + } + found + }; + // What the modules outside host's children import, which code can name instead, until no + // import adds a path. An import in a block binds its name for the block alone. + let mut imports = Imports::default(); + loop { + let mut added = false; + for reference in scan + .references + .iter() + .filter(|reference| child_of(&reference.module).is_none() && !reference.local) + { + let Some(binding) = &reference.binding else { + continue; + }; + for path in scan.resolve(&reference.module, &reference.segments, &imports) { + if binding == "*" { + assert!( + targets(&path, &imports).is_empty(), + "{}:{}: the test cannot resolve the names of glob imports of host's \ + children", + reference.file, + reference.line + ); + let globs = imports.globs.entry(reference.module.clone()).or_default(); + added |= globs.insert(path); + } else { + let name = [reference.module.as_slice(), slice::from_ref(binding)].concat(); + added |= imports.names.entry(name).or_default().insert(path); + } + } + } + if !added { + break; + } + } + let mut graph: Graph = children + .iter() + .map(|child| (child.clone(), BTreeMap::new())) + .collect(); + for reference in &scan.references { + let Some(from) = child_of(&reference.module) else { + continue; + }; + let mut found = BTreeSet::new(); + for path in scan.resolve(&reference.module, &reference.segments, &imports) { + // A child's relative paths count only through the imports that bind them, so an + // import of the crate root or of `host` itself, under any name, could hide any + // reference. + if reference.binding.is_some() { + let mut alternatives = BTreeSet::new(); + imports.expand( + &scan.modules, + &path, + &mut alternatives, + &mut Questions::new(), + ); + assert!( + alternatives + .iter() + .all(|alternative| !alternative.is_empty() && alternative != &host), + "{}:{}: the test cannot follow imports of the crate root or of host in host's \ + children: name the children with crate::host::CHILD paths", + reference.file, + reference.line + ); + found.extend(names_under(&path, &imports)); + } + found.extend(targets(&path, &imports)); + } + for to in found { + if to == from { + continue; + } + let references = graph + .get_mut(&from) + .expect("a scanned module of host is in a child"); + let first = references + .get(&to) + .is_none_or(|first| (&reference.file, reference.line) < (&first.file, first.line)); + if first { + references.insert(to, reference.clone()); + } + } + } + graph +} + +/// Returns the shortest path of references from `from` to `to` in `graph`, with both, if there is +/// one. +fn shortest_path<'a>(graph: &'a Graph, from: &'a str, to: &'a str) -> Option> { + let mut previous: BTreeMap<&str, &str> = BTreeMap::new(); + let mut queue = VecDeque::from([from]); + while let Some(child) = queue.pop_front() { + if child == to { + let mut path = vec![to.to_owned()]; + let mut current = to; + while current != from { + current = previous[¤t]; + path.push(current.to_owned()); + } + path.reverse(); + return Some(path); + } + for next in graph.get(child).into_iter().flat_map(BTreeMap::keys) { + if next != from && !previous.contains_key(next.as_str()) { + previous.insert(next, child); + queue.push_back(next); + } + } + } + None +} + +/// Returns the cycles of `graph`, shortest first: for each reference between two children that +/// lies on a cycle, the shortest cycle through it, from its alphabetically first child. +fn cycles(graph: &Graph) -> Vec> { + let mut cycles = BTreeSet::new(); + for (from, references) in graph { + for to in references.keys() { + let Some(back) = shortest_path(graph, to, from) else { + continue; + }; + let mut cycle = vec![from.clone()]; + cycle.extend_from_slice(&back[..back.len() - 1]); + let first = (0..cycle.len()) + .min_by_key(|&index| &cycle[index]) + .unwrap_or(0); + cycle.rotate_left(first); + cycles.insert(cycle); + } + } + let mut cycles: Vec<_> = cycles.into_iter().collect(); + cycles.sort_by_key(Vec::len); + cycles +} + +/// Describes `cycles` of `graph`, with the first reference along each of their edges. +fn report(graph: &Graph, cycles: &[Vec]) -> String { + let mut report = String::from( + "host's children reference each other in cycles. Break each one, for example by moving \ + the items that one of its references names into a child below the others:\n", + ); + for cycle in cycles { + let _ = writeln!(report, "\n{} -> {}", cycle.join(" -> "), cycle[0]); + for (index, from) in cycle.iter().enumerate() { + let to = &cycle[(index + 1) % cycle.len()]; + let reference = &graph[from][to]; + let _ = writeln!( + report, + " {from} -> {to}: {}:{}: {}", + reference.file, + reference.line, + reference.segments.join("::").replacen("::::", "::", 1) + ); + } + } + report +} + +/// Returns host's children in layers, lowest first, given an acyclic `graph`: each child is one +/// layer above the highest child that it references. +fn layers(graph: &Graph) -> Vec> { + let mut layer: BTreeMap<&str, usize> = BTreeMap::new(); + while layer.len() < graph.len() { + let placed = layer.len(); + for (child, references) in graph { + if !layer.contains_key(child.as_str()) + && references.keys().all(|to| layer.contains_key(to.as_str())) + { + let above = references.keys().map(|to| layer[to.as_str()] + 1).max(); + layer.insert(child, above.unwrap_or(0)); + } + } + assert!(layer.len() > placed, "the graph has a cycle"); + } + let mut layers = vec![Vec::new(); layer.values().max().map_or(0, |top| top + 1)]; + for (child, index) in layer { + layers[index].push(child); + } + layers +} + +/// Returns the graph of the crate whose files `files` holds, by their paths. +fn graph_of(files: &[(&str, &str)]) -> Graph { + let files: BTreeMap<&str, &str> = files.iter().copied().collect(); + graph(&|path| files.get(path).map(|text| (*text).to_owned())) +} + +/// Returns the references between children that `graph` holds, as `FROM -> TO`. +fn edges(graph: &Graph) -> Vec { + graph + .iter() + .flat_map(|(from, references)| references.keys().map(move |to| format!("{from} -> {to}"))) + .collect() +} + +#[test] +fn the_children_of_host_reference_each_other_without_cycles() { + let directory = Path::new(env!("CARGO_MANIFEST_DIR")); + let graph = graph(&|path| fs::read_to_string(directory.join(path)).ok()); + assert!( + !edges(&graph).is_empty(), + "the test found no reference between host's children" + ); + let cycles = cycles(&graph); + assert!(cycles.is_empty(), "{}", report(&graph, &cycles)); + for (index, children) in layers(&graph).iter().enumerate() { + println!("layer {index}: {}", children.join(", ")); + } +} + +#[test] +fn only_production_paths_into_other_children_are_references() { + let graph = graph_of(&[ + ( + "src/lib.rs", + "pub mod host;\npub use host::e::{Exported, other as Renamed};\n", + ), + ( + "src/host/mod.rs", + "mod a;\nmod b;\nmod c;\nmod d;\nmod e;\n#[cfg(test)]\nmod tests;\n", + ), + ( + "src/host/a.rs", + r##"//! [`crate::host::b`] and [`super::b`] are links. +/* A block comment, /* nested */, names crate::host::b. */ +const TEXT: &str = "crate::host::b, \" super::b"; +const RAW: &str = r#"self::b " super::b"#; +const QUOTE: char = '"'; +#[cfg(test)] +use crate::host::b::Hidden; +#[cfg(all(test, unix))] +fn hidden() -> u8 { crate::host::b::VALUE } +#[cfg(any(test, feature = "cli"))] +use super::c::Counted; +fn shown<'a>(text: &'a str) -> &'a str { self::helper(crate::host::d::VALUE) } +"##, + ), + ( + "src/host/b/mod.rs", + "mod inner;\nmod imp {\n use super::super::{c::{self, Item}, e};\n}\n", + ), + ( + "src/host/b/inner.rs", + "fn value() -> u8 { super::super::d::VALUE }\n", + ), + ("src/host/c.rs", "use crate::Renamed;\n"), + ( + "src/host/d.rs", + "pub(crate) fn helper() { crate::host::d::helper(); self::super::e::f(); }\n", + ), + ("src/host/e.rs", "use std::fs;\n"), + ]); + assert_eq!( + edges(&graph), + [ + "a -> c", "a -> d", "b -> c", "b -> d", "b -> e", "c -> e", "d -> e" + ] + ); +} + +#[test] +fn test_only_code_ends_where_its_construct_does() { + // The test-only code of `a` names `t`, and the production code after each construct names + // another child. + let graph = graph_of(&[ + ("src/lib.rs", "pub mod host;\n"), + ( + "src/host/mod.rs", + "mod a;\nmod b;\nmod c;\nmod d;\nmod e;\nmod f;\nmod g;\nmod h;\nmod i;\nmod t;\n", + ), + ( + "src/host/a.rs", + r#"pub(crate) struct Fields { + #[cfg(test)] + probe: BTreeMap, + #[cfg(test)] + sized: Probe<{ 1 }, crate::host::t::T>, + #[cfg(test)] + r#type: u8, + shown: crate::host::b::B, +} +pub(crate) struct Pointers( + #[cfg(test)] fn(), + #[cfg(test)] unsafe extern "C" fn(crate::host::t::T), + crate::host::c::C, +); +#[cfg(test)] +fn sized() -> Probe<{ 1 }> { crate::host::t::probe() } +fn test_only() { + #![cfg(test)] + crate::host::t::probe(); +} +fn after() -> u8 { crate::host::d::VALUE } +fn arm(value: u8) -> u8 { + match value { + #[cfg(test)] + small if small < 2 => crate::host::t::VALUE, + large if large > 10 => crate::host::e::VALUE, + _ => 0, + } +} +fn generic<#[cfg(test)] const N: usize, T>() { crate::host::f::value(); } +fn bounded>() { crate::host::g::value(); } +fn closure() { let _ = |#[cfg(test)] probe: crate::host::t::T, value: u8| crate::host::h::f(value); } +fn literal() -> S { + S { #[cfg(test)] probe: crate::host::t::VALUE < 2, shown: crate::host::i::VALUE > 1 } +} +"#, + ), + ("src/host/b.rs", ""), + ("src/host/c.rs", ""), + ("src/host/d.rs", ""), + ("src/host/e.rs", ""), + ("src/host/f.rs", ""), + ("src/host/g.rs", ""), + ("src/host/h.rs", ""), + ("src/host/i.rs", ""), + ("src/host/t.rs", ""), + ]); + assert_eq!( + edges(&graph), + [ + "a -> b", "a -> c", "a -> d", "a -> e", "a -> f", "a -> g", "a -> h", "a -> i" + ] + ); +} + +#[test] +fn paths_in_attributes_are_references() { + // The test-only code of `a` names `t`. + let graph = graph_of(&[ + ("src/lib.rs", "pub mod host;\n"), + ( + "src/host/mod.rs", + "mod a;\nmod b;\nmod c;\nmod d;\nmod e;\nmod f;\nmod t;\n", + ), + ( + "src/host/a.rs", + r##"#[derive(Serialize)] +pub(crate) struct Serialized { + #[cfg(test)] + #[serde(with = "crate::host::t::format")] + probe: u8, + #[serde(serialize_with = "crate::host::b::serialize")] + cooked: u8, + #[serde(deserialize_with = r#"crate::host::c::deserialize"#)] + raw: u8, + #[serde(default = "crate::host::\u{64}::default")] + escaped: u8, + #[arg(value_parser = crate::host::t::parse)] + #[cfg(test)] + tested: u8, + #[cfg_attr(all(test, unix), arg(value_parser = crate::host::t::parse))] + #[arg(value_parser = crate::host::e::parse)] + parsed: u8, +} +mod inner { + #![attribute(crate::host::f::VALUE)] + #[cfg(test)] + fn probe() {} +} +"##, + ), + ("src/host/b.rs", ""), + ("src/host/c.rs", ""), + ("src/host/d.rs", ""), + ("src/host/e.rs", ""), + ("src/host/f.rs", ""), + ("src/host/t.rs", ""), + ]); + assert_eq!( + edges(&graph), + ["a -> b", "a -> c", "a -> d", "a -> e", "a -> f"] + ); +} + +#[test] +fn the_paths_in_the_input_of_a_macro_are_references() { + // The input of a macro need not be Rust, may hold paths in strings that the macro puts in + // attributes, and may lose its `cfg` attributes, while a template that names no path from a + // module names only what is in scope where it expands, whatever its matcher matches. + let graph = graph_of(&[ + ("src/lib.rs", "pub mod host;\n"), + ( + "src/host/mod.rs", + "mod a;\nmod b;\nmod c;\nmod d;\nmod e;\n", + ), + ( + "src/host/a.rs", + r#"macro_rules! ignore { + ($($tokens:tt)*) => {}; +} +macro_rules! matches_a_path { + (crate::host::b) => {}; +} +macro_rules! field { + ($attr:meta) => { struct S { #[$attr] value: u8 } }; +} +macro_rules! strip { + (#[cfg(test)] $item:item) => { $item }; +} +macro_rules! keep { + (#[cfg_attr(test, $attr:meta)] $item:item) => { #[$attr] $item }; +} +ignore!(mod missing; use ?; crate::host::b::f()); +field!(serde(serialize_with = "crate::host::c::serialize")); +strip!(#[cfg(test)] fn f() { crate::host::d::f(); }); +keep!(#[cfg_attr(test, arg(value_parser = crate::host::e::parse))] struct T;); +"#, + ), + ("src/host/b.rs", ""), + ("src/host/c.rs", ""), + ("src/host/d.rs", ""), + ("src/host/e.rs", ""), + ]); + assert_eq!(edges(&graph), ["a -> b", "a -> c", "a -> d", "a -> e"]); +} + +#[test] +fn paths_through_the_imports_of_the_crate_root_and_host_reach_every_child_they_may_name() { + let graph = graph_of(&[ + ( + "src/lib.rs", + r#"pub mod host; +pub use host::Forwarded; +#[cfg(unix)] +pub use host::a::Either; +#[cfg(windows)] +pub use host::b::Either; +use host::c as aliased; +pub use aliased::Through; +mod other { + mod inner { + fn f() { crate::host::a::f(); } + } + pub(crate) use crate::host::c::Nested; +} +mod bridge { + pub use crate::host::a::Bridged; + pub use crate::host::b::Other; + pub(crate) use crate::host::e as x; +} +pub use bridge::*; +use self::bridge as renamed; +pub use renamed::Other as Twice; +#[cfg(debug_assertions)] +use other as x; +"#, + ), + ( + "src/host/mod.rs", + r#"mod a; +mod b; +mod c; +mod d; +mod e; +#[cfg(unix)] +mod g; +#[cfg(windows)] +use self::b as g; +mod h; +mod i; +mod j; +mod k; +mod l; +mod m; +mod n; +pub use self::a::Forwarded; +"#, + ), + ("src/host/a.rs", ""), + ("src/host/b.rs", ""), + ("src/host/c.rs", ""), + ("src/host/d.rs", "use crate::{Either, Forwarded};\n"), + ("src/host/e.rs", "fn f() { crate::Through::g(); }\n"), + ("src/host/g.rs", ""), + ("src/host/h.rs", "use super::g::T;\n"), + ("src/host/i.rs", "use crate::other::Nested;\n"), + ("src/host/j.rs", "use crate::Bridged;\n"), + ("src/host/k.rs", "use crate::Twice;\n"), + // In release builds, `crate::x` is the glob's `bridge::x`, and in debug builds `other`. + ("src/host/l.rs", "fn f(_: crate::x::Nested) {}\n"), + ( + "src/host/m.rs", + "use crate::bridge::*;\nfn f(_: self::Bridged) {}\n", + ), + ( + "src/host/n.rs", + "use crate::other;\nfn f(_: other::Nested) {}\n", + ), + ]); + assert_eq!( + edges(&graph), + [ + "d -> a", "d -> b", "e -> c", "h -> b", "h -> g", "i -> c", "j -> a", "k -> b", + "l -> c", "l -> e", "m -> a", "m -> b", "m -> e", "n -> c" + ] + ); +} + +#[test] +fn paths_through_aliases_and_the_crate_roots_other_modules_are_references() { + let graph = graph_of(&[ + ( + "src/lib.rs", + r#"pub mod host; +pub mod prelude; +extern crate self as nvx; +use host as aliased_host; +pub use aliased_host::a::ThroughHost; +pub use nvx::host::b::ThroughSelf; +mod bridge { + pub(crate) use crate::host::c::C; +} +use bridge as alias; +use alias::*; +pub(crate) mod other { + pub(crate) use crate::host::d::D; +} +mod nested { + pub(crate) use crate::other as alias; +} +pub struct Alias; +fn helper() { + use crate::host::a::Alias; +} +pub struct Negated; +fn negated() -> bool { + if !{ use crate::host::b::Negated; true } {} + false +} +mod outer { + pub(crate) mod inner { + pub(crate) use crate::bridge::*; + } +} +"#, + ), + ("src/prelude.rs", "pub use crate::host::e::E;\n"), + ( + "src/host/mod.rs", + "mod a;\nmod b;\nmod c;\nmod d;\nmod e;\nmod f;\nmod g;\nmod h;\nmod i;\nmod j;\nmod k;\nmod \ + l;\nmod m;\n", + ), + ("src/host/a.rs", ""), + ("src/host/b.rs", ""), + ("src/host/c.rs", ""), + ("src/host/d.rs", ""), + ("src/host/e.rs", ""), + ("src/host/f.rs", "use crate::ThroughHost;\n"), + ("src/host/g.rs", "use crate::ThroughSelf;\n"), + ("src/host/h.rs", "fn f(_: crate::C) {}\n"), + ( + "src/host/i.rs", + "use crate::nested;\nfn f(_: nested::alias::D) {}\n", + ), + ("src/host/j.rs", "use crate::prelude::*;\n"), + // The root's `Alias` is its own: its function imports another for its body alone. + ("src/host/k.rs", "fn f(_: crate::Alias) {}\n"), + ( + "src/host/l.rs", + "use crate::outer;\nfn f(_: outer::inner::C) {}\n", + ), + // So is its `Negated`, which a block that `!` negates, and no macro, imports. + ("src/host/m.rs", "fn f(_: crate::Negated) {}\n"), + ]); + assert_eq!( + edges(&graph), + ["f -> a", "g -> b", "h -> c", "i -> d", "j -> e", "l -> c"] + ); + // A module that imports host's children with a glob stands for every one of them. + let graph = graph_of(&[ + ( + "src/lib.rs", + "pub mod host;\nmod everything {\n pub(crate) use crate::host::*;\n}\n", + ), + ("src/host/mod.rs", "mod a;\nmod b;\nmod c;\n"), + ( + "src/host/a.rs", + "use crate::everything;\nfn f() {\n everything::b::f();\n}\n", + ), + ("src/host/b.rs", ""), + ("src/host/c.rs", ""), + ]); + assert_eq!(edges(&graph), ["a -> b", "a -> c"]); +} + +#[test] +fn paths_from_the_names_of_the_crate_root_in_the_extern_prelude_are_references() { + // `a` names `nvx` before the crate root declares it, `host` may import any name with its + // glob, `l` and `p` declare their own `nvx`, which `::` or some configurations bypass, `r`, + // `s`, and `t` declare or import their own in every configuration, `u` only in a block, `w` + // and `y` in test builds alone, and `alpha` in one of two definitions of a module. + let graph = graph_of(&[ + ( + "src/lib.rs", + r#"pub mod host { + pub mod a { + pub(crate) fn f() { nvx::host::b::f() } + } + mod b; + mod c; + mod d; + mod e; + mod f; + mod g; + mod h; + mod i; + mod j; + mod k; + mod l; + mod m; + mod n; + mod o; + mod p; + mod q; + mod r; + mod s; + mod t; + mod u; + mod v; + mod w; + mod x; + mod y; + mod z; + mod alpha; + mod beta; + use crate::sys::*; + pub(crate) use nvx::host::k::K as Alias; +} +pub mod sys {} +extern crate self as nvx; +"#, + ), + ("src/host/b.rs", ""), + ("src/host/c.rs", "use nvx::host::d::D;\n"), + ("src/host/d.rs", ""), + ( + "src/host/e.rs", + "pub(crate) fn make() -> ::nvx::host::f::F {\n return ::nvx::host::g::make();\n}\n", + ), + ("src/host/f.rs", ""), + ("src/host/g.rs", ""), + ( + "src/host/h.rs", + "pub(crate) struct H {\n #[serde(with = \"nvx::host::i\")]\n field: u8,\n}\n", + ), + ("src/host/i.rs", ""), + ("src/host/j.rs", "fn f(_: crate::host::Alias) {}\n"), + ("src/host/k.rs", ""), + ( + "src/host/l.rs", + "mod nvx {}\nfn f(_: ::nvx::host::m::M) {}\n", + ), + ("src/host/m.rs", ""), + ( + "src/host/n.rs", + "fn f(x: i32) -> bool { x > ::nvx::host::o::VALUE }\n", + ), + ("src/host/o.rs", ""), + ( + "src/host/p.rs", + "#[cfg(debug_assertions)]\nmod nvx {}\n#[cfg(not(debug_assertions))]\nfn f(_: \ + nvx::host::q::Q) {}\n", + ), + ("src/host/q.rs", ""), + ( + "src/host/r.rs", + "mod nvx {\n pub(super) struct Local;\n}\nuse nvx as local;\nfn f(_: local::Local) {}\n", + ), + ( + "src/host/s.rs", + "#[cfg_attr(debug_assertions, allow(dead_code))]\npub(crate) mod nvx {}\nuse nvx as \ + local;\n", + ), + ( + "src/host/t.rs", + "use crate::sys as nvx;\nuse nvx as local;\n", + ), + ( + "src/host/u.rs", + "fn f() {\n use crate::sys as nvx;\n}\nfn g(_: nvx::host::v::V) {}\n", + ), + ("src/host/v.rs", ""), + ( + "src/host/w.rs", + "mod nvx {\n #![cfg(test)]\n}\nfn f(_: nvx::host::x::X) {}\n", + ), + ("src/host/x.rs", ""), + ("src/host/y.rs", "mod nvx;\nfn f(_: nvx::host::z::Z) {}\n"), + ("src/host/y/nvx.rs", "#![cfg(test)]\n"), + ("src/host/z.rs", ""), + ( + "src/host/alpha.rs", + "#[cfg(unix)]\nmod imp {\n mod nvx {}\n}\n#[cfg(windows)]\nmod imp {\n fn f() {\n \ + nvx::host::beta::f();\n }\n}\n", + ), + ("src/host/beta.rs", ""), + ]); + assert_eq!( + edges(&graph), + [ + "a -> b", + "alpha -> beta", + "c -> d", + "e -> f", + "e -> g", + "h -> i", + "j -> k", + "l -> m", + "n -> o", + "p -> q", + "u -> v", + "w -> x", + "y -> z" + ] + ); +} + +#[test] +fn long_paths_are_followed() { + let modules = (0..70).fold("pub(crate) struct T;".to_owned(), |inner, _| { + format!("pub(crate) mod m {{ {inner} }}") + }); + let path = format!("fn f(_: crate::host::b::{}T) {{}}", "m::".repeat(70)); + let graph = graph_of(&[ + ("src/lib.rs", "pub mod host;\n"), + ("src/host/mod.rs", "mod a;\nmod b;\n"), + ("src/host/a.rs", path.as_str()), + ("src/host/b.rs", modules.as_str()), + ]); + assert_eq!(edges(&graph), ["a -> b"]); +} + +#[test] +fn what_the_test_cannot_follow_is_refused() { + let cases: &[(&[(&str, &str)], &str)] = &[ + ( + &[ + ("src/lib.rs", "pub mod host;\n"), + ( + "src/host/mod.rs", + "#[cfg_attr(windows, path = \"a_windows.rs\")]\nmod a;\n", + ), + ("src/host/a.rs", ""), + ], + "src/host/mod.rs:1: the test does not follow #[path] attributes", + ), + ( + &[ + ("src/lib.rs", "pub mod host;\n"), + ("src/host/mod.rs", "#[r#path = \"renamed.rs\"]\nmod a;\n"), + ("src/host/a.rs", ""), + ], + "src/host/mod.rs:1: the test does not follow #[path] attributes", + ), + ( + &[ + ("src/lib.rs", "pub mod host;\npub use host::b::*;\n"), + ("src/host/mod.rs", "mod a;\nmod b;\n"), + ("src/host/a.rs", ""), + ("src/host/b.rs", ""), + ], + "src/lib.rs:2: the test cannot resolve the names of glob imports of host's children", + ), + ( + &[ + ("src/lib.rs", "pub mod host;\n"), + ("src/host/mod.rs", "mod a;\nmod b;\n"), + ( + "src/host/a.rs", + "use crate::host as aliased;\nfn f(_: aliased::b::B) {}\n", + ), + ("src/host/b.rs", ""), + ], + "src/host/a.rs:1: the test cannot follow imports of the crate root or of host in \ + host's children", + ), + ( + &[ + ("src/lib.rs", "pub mod host;\nextern crate self as nvx;\n"), + ("src/host/mod.rs", "mod a;\nmod b;\n"), + ( + "src/host/a.rs", + "use crate::nvx as root;\nfn f(_: root::host::b::B) {}\n", + ), + ("src/host/b.rs", ""), + ], + "src/host/a.rs:1: the test cannot follow imports of the crate root or of host in \ + host's children", + ), + ( + &[ + ("src/lib.rs", "pub mod host;\n"), + ("src/host/mod.rs", "mod a;\nmod b;\n"), + ( + "src/host/a.rs", + "macro_rules! call_b {\n () => { super::super::b::value(); };\n}\n", + ), + ("src/host/b.rs", ""), + ], + "src/host/a.rs:2: the test does not follow macro templates", + ), + ( + &[ + ("src/lib.rs", "pub mod host;\nextern crate self as nvx;\n"), + ("src/host/mod.rs", "mod a;\nmod b;\n"), + ( + "src/host/a.rs", + "macro_rules! call_b {\n () => { nvx::host::b::value(); };\n}\n", + ), + ("src/host/b.rs", ""), + ], + "src/host/a.rs:2: the test does not follow macro templates", + ), + ( + &[ + ("src/lib.rs", "pub mod host;\n"), + ("src/host/mod.rs", "mod a;\nmod b;\n"), + ( + "src/host/a.rs", + "macro_rules! call {\n ($root:ident) => { $root::host::b::value(); };\n}\n", + ), + ("src/host/b.rs", ""), + ], + "src/host/a.rs:2: the test does not follow macro templates", + ), + ( + &[ + ("src/lib.rs", "pub mod host;\n"), + ("src/host/mod.rs", "mod a;\nmod b;\n"), + ( + "src/host/a.rs", + "macro_rules! call {\n ($($root:tt)*) => { $($root)*::host::b::value(); \ + };\n}\n", + ), + ("src/host/b.rs", ""), + ], + "src/host/a.rs:2: the test does not follow macro templates", + ), + ( + &[ + ("src/lib.rs", "pub mod host;\n"), + ("src/host/mod.rs", "mod a;\nmod b;\n"), + ( + "src/host/a.rs", + "macro_rules! call {\n ($($segment:ident)*) => { $($segment)::*(); };\n}\n", + ), + ("src/host/b.rs", ""), + ], + "src/host/a.rs:2: the test does not follow macro templates", + ), + ( + &[ + ("src/lib.rs", "pub mod host;\n"), + ("src/host/mod.rs", "mod a;\nmod b;\n"), + ( + "src/host/a.rs", + "macro_rules! call {\n ($separator:tt) => { crate $separator host::b::value(); \ + };\n}\n", + ), + ("src/host/b.rs", ""), + ], + "src/host/a.rs:2: the test does not follow macro templates", + ), + ( + &[ + ("src/lib.rs", "pub mod host;\n"), + ("src/host/mod.rs", "mod a;\nmod b;\n"), + ( + "src/host/a.rs", + "macro_rules! call {\n ($($root:tt)*) => { $($root)* host::b::value(); };\n}\n", + ), + ("src/host/b.rs", ""), + ], + "src/host/a.rs:2: the test does not follow macro templates", + ), + ( + &[ + ("src/lib.rs", "pub mod host;\n"), + ("src/host/mod.rs", "mod a;\nmod b;\n"), + ( + "src/host/a.rs", + "macro_rules! call {\n ($root:tt, $($tail:tt)*) => { $root $($tail)*(); };\n}\n", + ), + ("src/host/b.rs", ""), + ], + "src/host/a.rs:2: the test does not follow macro templates", + ), + ( + &[ + ("src/lib.rs", "pub mod host;\n"), + ("src/host/mod.rs", "mod a;\nmod b;\n"), + ( + "src/host/a.rs", + "macro_rules! call {\n ($root:ident, $($segment:ident)*) => { $root \ + $(::$segment)*(); };\n}\n", + ), + ("src/host/b.rs", ""), + ], + "src/host/a.rs:2: the test does not follow macro templates", + ), + ( + &[ + ("src/lib.rs", "pub mod host;\n"), + ("src/host/mod.rs", "mod a;\nmod b;\n"), + ( + "src/host/a.rs", + "macro_rules! field {\n ($with:literal) => { struct S { #[serde(with = \ + $with)] value: u8 } };\n}\n", + ), + ("src/host/b.rs", ""), + ], + "src/host/a.rs:2: the test does not follow macro templates", + ), + ( + &[ + ("src/lib.rs", "pub mod host;\n"), + ("src/host/mod.rs", "mod a;\nmod b;\n"), + ( + "src/host/a.rs", + "macro_rules! field {\n ($attr:ident) => { #[$attr(with = \ + \"crate::host::b\")] struct S; };\n}\n", + ), + ("src/host/b.rs", ""), + ], + "src/host/a.rs:2: the test does not follow macro templates", + ), + ]; + for (files, expected) in cases { + let Err(payload) = panic::catch_unwind(|| graph_of(files)) else { + panic!("the test followed the crate that it should refuse with {expected:?}"); + }; + let refusal = payload.downcast_ref::().map_or("", String::as_str); + assert!( + refusal.starts_with(expected), + "{refusal:?} does not start with {expected:?}" + ); + } +} + +#[test] +fn each_cycle_is_listed_with_one_reference_per_edge() { + let graph = graph_of(&[ + ("src/lib.rs", "pub mod host;\nextern crate self as nvx;\n"), + ("src/host/mod.rs", "mod a;\nmod b;\nmod c;\n"), + ("src/host/a.rs", "use super::b::{B, Other};\n"), + ( + "src/host/b.rs", + "use super::c::C;\nfn f() {\n crate::host::a::f();\n}\n", + ), + ("src/host/c.rs", "use ::nvx::host::a::A;\n"), + ]); + let cycles = cycles(&graph); + assert_eq!(cycles, [vec!["a", "b"], vec!["a", "b", "c"]]); + let report = report(&graph, &cycles); + for line in [ + "a -> b -> a", + " a -> b: src/host/a.rs:1: super::b::B", + " b -> a: src/host/b.rs:3: crate::host::a::f", + "a -> b -> c -> a", + " b -> c: src/host/b.rs:1: super::c::C", + " c -> a: src/host/c.rs:1: ::nvx::host::a::A", + ] { + assert!( + report.lines().any(|reported| reported == line), + "{line:?} is not in:\n{report}" + ); + } +}