Skip to content

Commit 92007a5

Browse files
Fix crash when using @source containing .. (#14831)
This PR fixes an issue where a `@source` crashes when the path eventually resolves to a path ending in `..`. We have to make sure that we canonicalize the path to make sure that we are working with the real directory. --------- Co-authored-by: Jordan Pittman <jordan@cryptica.me>
1 parent eb54dcd commit 92007a5

File tree

10 files changed

+134
-14
lines changed

10 files changed

+134
-14
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1010
### Fixed
1111

1212
- Detect classes in new files when using `@tailwindcss/postcss` ([#14829](https://github.com/tailwindlabs/tailwindcss/pull/14829))
13+
- Fix crash when using `@source` containing `..` ([#14831](https://github.com/tailwindlabs/tailwindcss/pull/14831))
1314
- _Upgrade (experimental)_: Install `@tailwindcss/postcss` next to `tailwindcss` ([#14830](https://github.com/tailwindlabs/tailwindcss/pull/14830))
1415

1516
## [4.0.0-alpha.31] - 2024-10-29

crates/oxide/src/glob.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,10 @@ pub fn hoist_static_glob_parts(entries: &Vec<GlobEntry>) -> Vec<GlobEntry> {
4242
// folders.
4343
if pattern.is_empty() && base.is_file() {
4444
result.push(GlobEntry {
45+
// SAFETY: `parent()` will be available because we verify `base` is a file, thus a
46+
// parent folder exists.
4547
base: base.parent().unwrap().to_string_lossy().to_string(),
48+
// SAFETY: `file_name()` will be available because we verify `base` is a file.
4649
pattern: base.file_name().unwrap().to_string_lossy().to_string(),
4750
});
4851
}
@@ -100,6 +103,7 @@ pub fn optimize_patterns(entries: &Vec<GlobEntry>) -> Vec<GlobEntry> {
100103
GlobEntry {
101104
base,
102105
pattern: match size {
106+
// SAFETY: we can unwrap here because we know that the size is 1.
103107
1 => patterns.next().unwrap(),
104108
_ => {
105109
let mut patterns = patterns.collect::<Vec<_>>();

crates/oxide/src/lib.rs

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -322,17 +322,27 @@ impl Scanner {
322322

323323
fn join_paths(a: &str, b: &str) -> PathBuf {
324324
let mut tmp = a.to_owned();
325+
let b = b.trim_end_matches("**/*").trim_end_matches('/');
326+
327+
if b.starts_with('/') {
328+
return PathBuf::from(b);
329+
}
330+
331+
// On Windows a path like C:/foo.txt is absolute but C:foo.txt is not
332+
// (the 2nd is relative to the CWD)
333+
if b.chars().nth(1) == Some(':') && b.chars().nth(2) == Some('/') {
334+
return PathBuf::from(b);
335+
}
325336

326337
tmp += "/";
327-
tmp += b.trim_end_matches("**/*").trim_end_matches('/');
338+
tmp += b;
328339

329340
PathBuf::from(&tmp)
330341
}
331342

332-
for path in auto_sources
333-
.iter()
334-
.map(|source| join_paths(&source.base, &source.pattern))
335-
{
343+
for path in auto_sources.iter().filter_map(|source| {
344+
dunce::canonicalize(join_paths(&source.base, &source.pattern)).ok()
345+
}) {
336346
// Insert a glob for the base path, so we can see new files/folders in the directory itself.
337347
self.globs.push(GlobEntry {
338348
base: path.to_string_lossy().into(),

crates/oxide/src/parser.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -362,6 +362,8 @@ impl<'a> Extractor<'a> {
362362
}
363363

364364
// The ':` must be preceded by a-Z0-9 because it represents a property name.
365+
// SAFETY: the Self::validate_arbitrary_property function from above validates that the
366+
// `:` exists.
365367
let colon = utility.find(":").unwrap();
366368

367369
if !utility

crates/oxide/src/scanner/allowed_paths.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,13 @@ static IGNORED_FILES: sync::LazyLock<Vec<&'static str>> = sync::LazyLock::new(||
2525
static IGNORED_CONTENT_DIRS: sync::LazyLock<Vec<&'static str>> =
2626
sync::LazyLock::new(|| vec![".git"]);
2727

28-
#[tracing::instrument(skip(root))]
28+
#[tracing::instrument(skip_all)]
2929
pub fn resolve_allowed_paths(root: &Path) -> impl Iterator<Item = DirEntry> {
3030
// Read the directory recursively with no depth limit
3131
read_dir(root, None)
3232
}
3333

34-
#[tracing::instrument(skip(root))]
34+
#[tracing::instrument(skip_all)]
3535
pub fn resolve_paths(root: &Path) -> impl Iterator<Item = DirEntry> {
3636
WalkBuilder::new(root)
3737
.hidden(false)
@@ -40,7 +40,7 @@ pub fn resolve_paths(root: &Path) -> impl Iterator<Item = DirEntry> {
4040
.filter_map(Result::ok)
4141
}
4242

43-
#[tracing::instrument(skip(root))]
43+
#[tracing::instrument(skip_all)]
4444
pub fn read_dir(root: &Path, depth: Option<usize>) -> impl Iterator<Item = DirEntry> {
4545
WalkBuilder::new(root)
4646
.hidden(false)

crates/oxide/tests/scanner.rs

Lines changed: 86 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ mod scanner {
88
use tailwindcss_oxide::*;
99
use tempfile::tempdir;
1010

11-
fn create_files_in(dir: &path::PathBuf, paths: &[(&str, &str)]) {
11+
fn create_files_in(dir: &path::Path, paths: &[(&str, &str)]) {
1212
// Create the necessary files
1313
for (path, contents) in paths {
1414
// Ensure we use the right path separator for the current platform
@@ -334,6 +334,53 @@ mod scanner {
334334
);
335335
}
336336

337+
#[test]
338+
fn it_should_be_possible_to_scan_in_the_parent_directory() {
339+
let candidates = scan_with_globs(
340+
&[("foo/bar/baz/foo.html", "content-['foo.html']")],
341+
vec!["./foo/bar/baz/.."],
342+
)
343+
.1;
344+
345+
assert_eq!(candidates, vec!["content-['foo.html']"]);
346+
}
347+
348+
#[test]
349+
fn it_should_scan_files_without_extensions() {
350+
// These look like folders, but they are files
351+
let candidates =
352+
scan_with_globs(&[("my-file", "content-['my-file']")], vec!["./my-file"]).1;
353+
354+
assert_eq!(candidates, vec!["content-['my-file']"]);
355+
}
356+
357+
#[test]
358+
fn it_should_scan_folders_with_extensions() {
359+
// These look like files, but they are folders
360+
let candidates = scan_with_globs(
361+
&[
362+
(
363+
"my-folder.templates/foo.html",
364+
"content-['my-folder.templates/foo.html']",
365+
),
366+
(
367+
"my-folder.bin/foo.html",
368+
"content-['my-folder.bin/foo.html']",
369+
),
370+
],
371+
vec!["./my-folder.templates", "./my-folder.bin"],
372+
)
373+
.1;
374+
375+
assert_eq!(
376+
candidates,
377+
vec![
378+
"content-['my-folder.bin/foo.html']",
379+
"content-['my-folder.templates/foo.html']",
380+
]
381+
);
382+
}
383+
337384
#[test]
338385
fn it_should_scan_content_paths() {
339386
let candidates = scan_with_globs(
@@ -349,6 +396,44 @@ mod scanner {
349396
assert_eq!(candidates, vec!["content-['foo.styl']"]);
350397
}
351398

399+
#[test]
400+
fn it_should_scan_absolute_paths() {
401+
// Create a temporary working directory
402+
let dir = tempdir().unwrap().into_path();
403+
404+
// Initialize this directory as a git repository
405+
let _ = Command::new("git").arg("init").current_dir(&dir).output();
406+
407+
// Create files
408+
create_files_in(
409+
&dir,
410+
&[
411+
("project-a/index.html", "content-['project-a/index.html']"),
412+
("project-b/index.html", "content-['project-b/index.html']"),
413+
],
414+
);
415+
416+
// Get POSIX-style absolute path
417+
let full_path = format!("{}", dir.display()).replace('\\', "/");
418+
419+
let sources = vec![GlobEntry {
420+
base: full_path.clone(),
421+
pattern: full_path.clone(),
422+
}];
423+
424+
let mut scanner = Scanner::new(Some(sources));
425+
let candidates = scanner.scan();
426+
427+
// We've done the initial scan and found the files
428+
assert_eq!(
429+
candidates,
430+
vec![
431+
"content-['project-a/index.html']".to_owned(),
432+
"content-['project-b/index.html']".to_owned(),
433+
]
434+
);
435+
}
436+
352437
#[test]
353438
fn it_should_scan_content_paths_even_when_they_are_git_ignored() {
354439
let candidates = scan_with_globs(

integrations/cli/index.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -431,6 +431,9 @@ test(
431431
432432
/* bar.html is git ignored, but explicitly listed here to scan */
433433
@source '../../project-d/src/bar.html';
434+
435+
/* Project E's source ends with '..' */
436+
@source '../../project-e/nested/..';
434437
`,
435438

436439
// Project A is the current folder, but we explicitly configured
@@ -553,6 +556,13 @@ test(
553556
class="content-['project-d/my-binary-file.bin']"
554557
></div>
555558
`,
559+
560+
// Project E's `@source "project-e/nested/.."` ends with `..`, which
561+
// should look for files in `project-e` itself.
562+
'project-e/index.html': html`<div class="content-['project-e/index.html']"></div>`,
563+
'project-e/nested/index.html': html`<div
564+
class="content-['project-e/nested/index.html']"
565+
></div>`,
556566
},
557567
},
558568
async ({ fs, exec, spawn, root }) => {
@@ -599,6 +609,14 @@ test(
599609
--tw-content: 'project-d/src/index.html';
600610
content: var(--tw-content);
601611
}
612+
.content-\\[\\'project-e\\/index\\.html\\'\\] {
613+
--tw-content: 'project-e/index.html';
614+
content: var(--tw-content);
615+
}
616+
.content-\\[\\'project-e\\/nested\\/index\\.html\\'\\] {
617+
--tw-content: 'project-e/nested/index.html';
618+
content: var(--tw-content);
619+
}
602620
@supports (-moz-orient: inline) {
603621
@layer base {
604622
*, ::before, ::after, ::backdrop {

integrations/vite/index.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -679,7 +679,7 @@ for (let transformer of ['postcss', 'lightningcss']) {
679679
},
680680
async ({ root, fs, exec }) => {
681681
await expect(() =>
682-
exec('pnpm vite build', { cwd: path.join(root, 'project-a') }),
682+
exec('pnpm vite build', { cwd: path.join(root, 'project-a') }, { ignoreStdErr: true }),
683683
).rejects.toThrowError('The `source(../i-do-not-exist)` does not exist')
684684

685685
let files = await fs.glob('project-a/dist/**/*.css')

packages/@tailwindcss-upgrade/src/template/codemods/prefix.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ function extractV3Base(
5959
// ^^^^^^^^^ -> Base
6060
let rawVariants = segment(rawCandidate, ':')
6161

62-
// Safety: At this point it is safe to use TypeScript's non-null assertion
62+
// SAFETY: At this point it is safe to use TypeScript's non-null assertion
6363
// operator because even if the `input` was an empty string, splitting an
6464
// empty string by `:` will always result in an array with at least one
6565
// element.

packages/tailwindcss/src/compile.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ export function compileCandidates(
9797
}
9898

9999
astNodes.sort((a, z) => {
100-
// Safety: At this point it is safe to use TypeScript's non-null assertion
100+
// SAFETY: At this point it is safe to use TypeScript's non-null assertion
101101
// operator because if the ast nodes didn't exist, we introduced a bug
102102
// above, but there is no need to re-check just to be sure. If this relied
103103
// on pure user input, then we would need to check for its existence.
@@ -194,7 +194,7 @@ export function applyVariant(
194194
return
195195
}
196196

197-
// Safety: At this point it is safe to use TypeScript's non-null assertion
197+
// SAFETY: At this point it is safe to use TypeScript's non-null assertion
198198
// operator because if the `candidate.root` didn't exist, `parseCandidate`
199199
// would have returned `null` and we would have returned early resulting in
200200
// not hitting this code path.
@@ -322,7 +322,7 @@ function getPropertySort(nodes: AstNode[]) {
322322
let q: AstNode[] = nodes.slice()
323323

324324
while (q.length > 0) {
325-
// Safety: At this point it is safe to use TypeScript's non-null assertion
325+
// SAFETY: At this point it is safe to use TypeScript's non-null assertion
326326
// operator because we guarded against `q.length > 0` above.
327327
let node = q.shift()!
328328
if (node.kind === 'declaration') {

0 commit comments

Comments
 (0)