Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 43 additions & 1 deletion laravel-lsp/src/composer_autoload.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,25 @@ use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};

use crate::path_containment::path_within_root;

/// Cached autoload data for one Laravel project. The PSR-4 prefix list is
/// sorted longest-first so a more specific prefix wins over a less specific
/// one (e.g. `App\Models\` over `App\`).
#[derive(Debug)]
pub struct ComposerAutoload {
/// (psr4_prefix_no_trailing_backslash, absolute_source_roots)
prefixes: Vec<(String, Vec<PathBuf>)>,
/// The project root this autoload data was loaded for. Every candidate
/// `resolve` builds is gated against this with the fail-closed
/// [`path_within_root`] guard, so a `..`-bearing FQCN — or a PSR-4 mapping
/// / under-root symlink pointing outside the tree — can't yield a path that
/// escapes the root and is then `stat`'d and returned (issue #222,
/// containment lineage #130 → #143 → #148 → #194 → #199 → #201 → #214 →
/// #218). Stored at construction rather than threaded per-call so resolution
/// is bound to exactly the root the PSR-4 mappings were resolved against —
/// a caller can't hand `resolve` a mismatched root.
project_root: PathBuf,
}

impl ComposerAutoload {
Expand All @@ -44,6 +56,16 @@ impl ComposerAutoload {
/// FQCNs may have a leading `\` (fully qualified) — stripped before
/// lookup. The match must end on a namespace separator boundary so
/// `App\Models` doesn't accidentally match a prefix `App\Mo`.
///
/// Every candidate is gated by the fail-closed [`path_within_root`] guard
/// before the on-disk check (issue #222, containment lineage
/// #130 → #143 → #148 → #194 → #199 → #201 → #214 → #218): a `..`-bearing
/// FQCN, a PSR-4 mapping pointing outside the tree, or an under-root symlink
/// along the candidate path would otherwise produce a path that escapes
/// [`Self::project_root`] and is then `stat`'d and returned — so a candidate
/// that canonicalizes outside the root (or can't be proven in-root) is
/// refused (skip to the next `source_root`), mirroring the #199 / PR #221
/// pattern.
pub fn resolve(&self, fqcn: &str) -> Option<PathBuf> {
let normalized = fqcn.trim_start_matches('\\');
for (prefix, source_roots) in &self.prefixes {
Expand All @@ -69,6 +91,18 @@ impl ComposerAutoload {
rel_path.set_extension("php");
for source_root in source_roots {
let candidate = source_root.join(&rel_path);
// The PSR-4 remainder may carry `..` segments, which
// `PathBuf::push`/`join` appends literally — or the mapped
// `source_root` itself (an absolute / `..` PSR-4 value) or an
// under-root symlink along the way may point outside the tree.
// Either way the candidate can escape the project root and be
// `stat`'d/returned as an out-of-root read primitive. Gate it
// with the fail-closed `path_within_root` guard before the
// on-disk check: a candidate that canonicalizes outside the root
// (or can't be proven in-root) is skipped (issue #222).
if !path_within_root(&candidate, &self.project_root) {
continue;
}
if candidate.exists() {
return Some(candidate);
}
Expand Down Expand Up @@ -167,7 +201,15 @@ impl ComposerAutoload {
// would otherwise match an FQCN like `App\Models\User`.
prefixes.sort_by_key(|entry| std::cmp::Reverse(entry.0.len()));

Self { prefixes }
Self {
prefixes,
// Bind the containment guard in `resolve` to the root these PSR-4
// mappings were resolved against. Stored as given (not
// canonicalized): `path_within_root` canonicalizes both sides
// itself, so the macOS `/var`→`/private/var` symlinked-root case
// is handled there.
project_root: project_root.to_path_buf(),
}
}

/// Pull PSR-4 entries out of an `autoload` (or `autoload-dev`) JSON
Expand Down
168 changes: 168 additions & 0 deletions laravel-lsp/src/tests/composer_autoload_containment.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
//! PSR-4 FQCN → file resolution containment for the Composer autoload resolver
//! (issue #222), extending the `path_within_root` containment lineage
//! (#130 → #143 → #148 → #194 → #199 → #201 → #214 → #218) to the
//! higher-priority FS-touching resolver that still lacked the guard.
//!
//! `ComposerAutoload::resolve` (in `composer_autoload.rs`) maps a PSR-4 FQCN to
//! a candidate file by splitting the post-prefix remainder on `\` and
//! `PathBuf::push`-ing each segment onto the mapped `source_root`, then returns
//! the candidate on a bare `candidate.exists()`. `push`/`join` appends a `..`
//! segment literally (it does not resolve it), and `source_root` is itself
//! derived from a PSR-4 mapping value in `composer.json` /
//! `vendor/composer/installed.json`. So a `..`-bearing FQCN — or a mapping /
//! under-root symlink pointing outside the tree — yields a candidate that
//! escapes the project root and is then `stat`'d and returned: the same
//! out-of-root read-primitive shape the lineage exists to close.
//!
//! `resolve` is the *higher-priority* branch in `class_locator.rs` — it runs
//! before the heuristic `find_php_class_file_by_fqcn` that #218/PR #221 guarded
//! — yet was the one FS-touching resolver in the lineage with no containment
//! guard. The fix gates every candidate with the fail-closed
//! [`laravel_lsp::path_containment::path_within_root`] guard before the on-disk
//! check; the project root is stored on `ComposerAutoload` at construction
//! (`load` / `for_project` both already receive it), so resolution is bound to
//! exactly the root the PSR-4 mappings were resolved against. These tests pin
//! that invariant.
//!
//! Each case is *discriminating*: the escaping file is written to disk OUTSIDE
//! the root, so without the guard the resolver would `candidate.exists()` it and
//! return `Some(<out-of-root path>)`. A `None` result can therefore only come
//! from the containment guard, never from absence — the precondition assertions
//! make that explicit.

use laravel_lsp::composer_autoload::ComposerAutoload;
use std::path::Path;
use tempfile::TempDir;

/// Write a file, creating parent directories as needed.
fn write_file(path: &Path, body: &str) {
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, body).unwrap();
}

// ---------------------------------------------------------------------------
// Negative: `..` segments in the FQCN escape the root
// ---------------------------------------------------------------------------

#[test]
fn psr4_fqcn_with_dotdot_escaping_root_is_refused() {
// composer.json maps `App\` → `app/`. The FQCN `App\..\..\secret` builds the
// candidate `<root>/app/../../secret.php`, which `PathBuf::push` leaves
// literal and canonicalizes to `<tmp>/secret.php` — outside the project root.
let tmp = TempDir::new().unwrap();
let root = tmp.path().join("project");
// `app/` must exist so the `..` candidate canonicalizes (canonicalize
// resolves each component, so `app/..` requires `app` to be a real dir).
std::fs::create_dir_all(root.join("app")).unwrap();
write_file(
&root.join("composer.json"),
r#"{ "autoload": { "psr-4": { "App\\": "app/" } } }"#,
);

let secret = tmp.path().join("secret.php");
std::fs::write(&secret, "<?php\nclass secret {}").unwrap();

// Precondition: the candidate `resolve` builds exists on disk and resolves
// OUTSIDE the root — so a `None` result proves the guard fired, not mere
// absence. (Without the guard, `candidate.exists()` is true and `resolve`
// returns `Some`.)
let candidate = root.join("app").join("..").join("..").join("secret.php");
assert_eq!(
candidate.canonicalize().unwrap(),
secret.canonicalize().unwrap(),
"precondition: the `..` candidate resolves to the out-of-root secret file"
);

let autoload = ComposerAutoload::load(&root);
assert_eq!(
autoload.resolve("App\\..\\..\\secret"),
None,
"a PSR-4 FQCN whose `..` segments escape the project root must be refused \
by the fail-closed path_within_root guard, not read"
);
}

// ---------------------------------------------------------------------------
// Positive control: a normal in-root PSR-4 FQCN still resolves
// ---------------------------------------------------------------------------

#[test]
fn psr4_fqcn_in_root_resolves_to_some_path() {
// The guard must not drop legitimate in-root candidates: a normal `App\`
// PSR-4 mapping with the file present must still resolve.
let tmp = TempDir::new().unwrap();
let root = tmp.path().to_path_buf();
write_file(
&root.join("composer.json"),
r#"{ "autoload": { "psr-4": { "App\\": "app/" } } }"#,
);
write_file(
&root.join("app").join("Models").join("User.php"),
"<?php\nnamespace App\\Models;\nclass User {}",
);

let autoload = ComposerAutoload::load(&root);
let resolved = autoload
.resolve("App\\Models\\User")
.expect("a normal in-root PSR-4 FQCN must resolve to its file");
assert!(
resolved.ends_with("app/Models/User.php"),
"App\\Models\\User must resolve to app/Models/User.php; got {resolved:?}"
);
}

// ---------------------------------------------------------------------------
// Negative (unix): an under-root symlink in the PSR-4 source path escapes
// ---------------------------------------------------------------------------

#[cfg(unix)]
#[test]
fn psr4_fqcn_through_under_root_symlink_escaping_root_is_refused() {
// An under-root path component is a symlink whose target is OUTSIDE the
// root. composer.json maps `App\` → `app/`; the FQCN `App\Evil\Secret`
// builds `<root>/app/Evil/Secret.php`, where `<root>/app/Evil` -> `<tmp>/outside`,
// so the candidate canonicalizes to `<tmp>/outside/Secret.php` — the #55/#134
// symlink escape leg a lexical fallback would admit but the canonicalize-based
// fail-closed guard catches.
let tmp = TempDir::new().unwrap();
let root = tmp.path().join("project");
std::fs::create_dir_all(root.join("app")).unwrap();
write_file(
&root.join("composer.json"),
r#"{ "autoload": { "psr-4": { "App\\": "app/" } } }"#,
);

let outside = tmp.path().join("outside");
std::fs::create_dir_all(&outside).unwrap();
let secret = outside.join("Secret.php");
std::fs::write(&secret, "<?php\nnamespace App\\Evil;\nclass Secret {}").unwrap();

let evil_link = root.join("app").join("Evil");
std::os::unix::fs::symlink(&outside, &evil_link).unwrap();

// Precondition: the candidate exists through the symlink and resolves
// outside the root — so `None` can only be the guard refusing it.
let candidate = root.join("app").join("Evil").join("Secret.php");
assert_eq!(
candidate.canonicalize().unwrap(),
secret.canonicalize().unwrap(),
"precondition: the candidate resolves through the symlink to the \
out-of-root file"
);
assert!(
!candidate
.canonicalize()
.unwrap()
.starts_with(root.canonicalize().unwrap()),
"precondition: the resolved target escapes the project root"
);

let autoload = ComposerAutoload::load(&root);
assert_eq!(
autoload.resolve("App\\Evil\\Secret"),
None,
"a PSR-4 FQCN candidate whose path crosses an under-root symlink resolving \
outside the root must be refused — the canonicalize-based guard catches \
the escape the lexical fallback would admit"
);
}
1 change: 1 addition & 0 deletions laravel-lsp/src/tests/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ mod code_action_create_containment;
mod code_lens_opt_in;
mod component_file_navigation_containment;
mod component_navigation_containment;
mod composer_autoload_containment;
mod diagnostic_severity;
mod directive_navigation_containment;
mod dynamic_where_sparseness;
Expand Down