Skip to content

Commit 8845304

Browse files
committed
fix: resolve project-tree paths with platform path rules on Windows
1 parent 1654c77 commit 8845304

26 files changed

Lines changed: 525 additions & 292 deletions

‎crates/sqlbuild-cache/src/artifacts/constants.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,5 @@
22
pub const NEW_FILE_MODE: u32 = 0o666;
33
/// The extension every compiled SQL test artifact carries.
44
pub(crate) const SQL_EXTENSION: &str = "sql";
5+
#[cfg(unix)]
56
pub(crate) const NANOS_PER_SECOND: i64 = 1_000_000_000;

‎crates/sqlbuild-core/src/posix_path/constants.rs‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,4 @@ pub(crate) const ROOT: &str = "/";
55
/// POSIX keeps exactly two leading slashes as their own root.
66
pub(crate) const DOUBLE_ROOT: &str = "//";
77
pub(crate) const CURRENT: &str = ".";
8-
pub(crate) const PARENT: &str = "..";
98
pub(crate) const SUFFIX_MARK: char = '.';

‎crates/sqlbuild-core/src/posix_path/main/mod.rs‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
pub mod posix_escapes_root;
21
pub mod posix_join;
32
pub mod posix_name;
43
pub mod posix_parent;

‎crates/sqlbuild-core/src/posix_path/main/posix_escapes_root.rs‎

Lines changed: 0 additions & 12 deletions
This file was deleted.

‎crates/sqlbuild-core/src/posix_path/tests/test_posix_path.rs‎

Lines changed: 1 addition & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
1-
use crate::posix_path::main::posix_escapes_root::posix_escapes_root;
21
use crate::posix_path::main::posix_join::posix_join;
32
use crate::posix_path::main::posix_name::posix_name;
43
use crate::posix_path::main::posix_parent::posix_parent;
54
use crate::posix_path::main::posix_parts::posix_parts;
65
use crate::posix_path::main::posix_suffix::posix_suffix;
7-
use crate::posix_path::tests::test_types::{EscapesRootTestCase, PosixPathTestCase};
6+
use crate::posix_path::tests::test_types::PosixPathTestCase;
87

98
#[test]
109
fn given_paths_when_reading_pure_posix_parts_then_they_match_pathlib() {
@@ -141,67 +140,3 @@ fn given_paths_when_reading_pure_posix_parts_then_they_match_pathlib() {
141140
);
142141
}
143142
}
144-
145-
#[test]
146-
fn given_paths_when_checking_for_escapes_then_absolute_and_parent_paths_escape() {
147-
let test_cases = [
148-
EscapesRootTestCase {
149-
description: "a relative file",
150-
path: "models/a.sql",
151-
expected_escapes: false,
152-
},
153-
EscapesRootTestCase {
154-
description: "current and empty parts",
155-
path: "./a//b/",
156-
expected_escapes: false,
157-
},
158-
EscapesRootTestCase {
159-
description: "an empty path",
160-
path: "",
161-
expected_escapes: false,
162-
},
163-
EscapesRootTestCase {
164-
description: "the current directory",
165-
path: ".",
166-
expected_escapes: false,
167-
},
168-
EscapesRootTestCase {
169-
description: "an absolute path",
170-
path: "/abs/x.yml",
171-
expected_escapes: true,
172-
},
173-
EscapesRootTestCase {
174-
description: "a double-slash root",
175-
path: "//x",
176-
expected_escapes: true,
177-
},
178-
EscapesRootTestCase {
179-
description: "a leading parent",
180-
path: "../x.sql",
181-
expected_escapes: true,
182-
},
183-
EscapesRootTestCase {
184-
description: "an inner parent",
185-
path: "a/../x.sql",
186-
expected_escapes: true,
187-
},
188-
EscapesRootTestCase {
189-
description: "only a parent",
190-
path: "..",
191-
expected_escapes: true,
192-
},
193-
EscapesRootTestCase {
194-
description: "dots inside names",
195-
path: "a..b/..c",
196-
expected_escapes: false,
197-
},
198-
];
199-
for test_case in test_cases {
200-
assert_eq!(
201-
posix_escapes_root(test_case.path),
202-
test_case.expected_escapes,
203-
"{}",
204-
test_case.description
205-
);
206-
}
207-
}

‎crates/sqlbuild-core/src/posix_path/tests/test_types.rs‎

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,3 @@ pub(super) struct PosixPathTestCase {
77
/// `suffix` under Python 3.12 and under Python 3.14.
88
pub(super) expected_suffix: &'static str,
99
}
10-
11-
pub(super) struct EscapesRootTestCase {
12-
pub(super) description: &'static str,
13-
pub(super) path: &'static str,
14-
pub(super) expected_escapes: bool,
15-
}

‎crates/sqlbuild-python/src/bindings/_helpers/rules/rule_api.rs‎

Lines changed: 40 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
//! Custom-rule API decisions: finding positions, harness option TOML and project-tree paths.
22
3-
use std::path::PathBuf;
3+
use std::path::{Path, PathBuf};
44

55
use pyo3::exceptions::{PyAttributeError, PyOSError, PyValueError};
66
use pyo3::prelude::{
@@ -12,8 +12,8 @@ use pyo3::types::{
1212
use pyo3::{IntoPyObject, pyfunction, wrap_pyfunction};
1313
use sqlbuild_config::toml::main::toml_literal::toml_literal;
1414
use sqlbuild_config::toml::models::TomlLiteral;
15-
use sqlbuild_core::posix_path::main::posix_escapes_root::posix_escapes_root;
1615
use sqlbuild_rules::project_tree::errors::ProjectTreeFileError;
16+
use sqlbuild_rules::project_tree::main::project_path_escapes::project_path_escapes;
1717
use sqlbuild_rules::project_tree::main::project_tree_entries::project_tree_entries;
1818
use sqlbuild_rules::project_tree::main::project_tree_file::project_tree_file;
1919
use sqlbuild_rules::project_tree::main::tracked_text_suffixes::tracked_text_suffixes;
@@ -26,6 +26,7 @@ const RULE_USAGE_ERROR: &str = "RuleUsageError";
2626
const PROJECT_PATH_CLASS: &str = "ProjectPath";
2727
const BUILTINS_MODULE: &str = "builtins";
2828
const DEFAULT_POSITION: i64 = 1;
29+
#[cfg(unix)]
2930
const OS_MODULE: &str = "os";
3031
/// The error `os.lstat` raises for a path holding a NUL byte.
3132
const EMBEDDED_NUL_MESSAGE: &str = "lstat: embedded null character in path";
@@ -87,7 +88,7 @@ fn finding_position<'py>(
8788
node: &Bound<'py, PyAny>,
8889
) -> PyResult<(Bound<'py, PyAny>, Bound<'py, PyAny>)> {
8990
let py: Python<'py> = path.py();
90-
if posix_escapes_root(&path.str()?.to_string_lossy()) {
91+
if escapes(path)? {
9192
return Err(usage_error_for(
9293
"finding path must be project-relative: ",
9394
path,
@@ -141,12 +142,46 @@ fn rule_option_toml_value(value: &Bound<'_, PyAny>) -> PyResult<String> {
141142
/// Reject a project path that is absolute or climbs out of the project.
142143
#[pyfunction]
143144
pub(crate) fn validate_project_relative(path: &Bound<'_, PyAny>) -> Result<(), PyErr> {
144-
if posix_escapes_root(&path.str()?.to_string_lossy()) {
145+
if escapes(path)? {
145146
return Err(usage_error_for("project path escapes the project: ", path));
146147
}
147148
Ok(())
148149
}
149150

151+
/// Whether `str(path)` is absolute or climbs out of the project with `..`.
152+
fn escapes(path: &Bound<'_, PyAny>) -> PyResult<bool> {
153+
Ok(project_path_escapes(Path::new(
154+
&*path.str()?.to_string_lossy(),
155+
)))
156+
}
157+
158+
/// The `OSError` `Path.is_file()` raises for a stat failure it does not ignore.
159+
#[cfg(unix)]
160+
fn unreadable(py: Python<'_>, path: PathBuf, errno: i32) -> PyResult<PyErr> {
161+
let message: String = imported(py, OS_MODULE, "strerror")?
162+
.call1((errno,))?
163+
.extract()?;
164+
let Ok(path) = path.into_os_string().into_pyobject(py);
165+
Ok(PyOSError::new_err((errno, message, path.unbind())))
166+
}
167+
168+
/// The `OSError` `Path.is_file()` raises for a Windows stat failure it does not ignore.
169+
#[cfg(not(unix))]
170+
fn unreadable(py: Python<'_>, path: PathBuf, code: i32) -> PyResult<PyErr> {
171+
let detail: String = std::io::Error::from_raw_os_error(code).to_string();
172+
let detail: &str = detail
173+
.strip_suffix(&format!(" (os error {code})"))
174+
.unwrap_or(&detail)
175+
.trim_end_matches(|character: char| character <= ' ' || character == '.');
176+
let Ok(path) = path.into_os_string().into_pyobject(py);
177+
Ok(PyOSError::new_err((
178+
code,
179+
detail.to_owned(),
180+
path.unbind(),
181+
code,
182+
)))
183+
}
184+
150185
/// Every supported path under the resolved project `root`, as sorted `ProjectPath`s.
151186
#[pyfunction]
152187
fn project_tree_paths<'py>(root: &Bound<'py, PyAny>) -> PyResult<Bound<'py, PyTuple>> {
@@ -206,11 +241,7 @@ fn project_tree_file_path<'py>(
206241
),
207242
Err(ProjectTreeFileError::Symlink) => "project file must not be a symlink: ".to_owned(),
208243
Err(ProjectTreeFileError::Unreadable { path, errno }) => {
209-
let message: String = imported(py, OS_MODULE, "strerror")?
210-
.call1((errno,))?
211-
.extract()?;
212-
let Ok(path) = path.into_os_string().into_pyobject(py);
213-
return Err(PyOSError::new_err((errno, message, path.unbind())));
244+
return Err(unreadable(py, path, errno)?);
214245
}
215246
};
216247
Err(usage_error_for(&prefix, path))
Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1-
//! How the project walk classifies and filters directory entries.
1+
//! How the project walk classifies, filters and names directory entries.
22
3+
use std::ffi::{OsStr, OsString};
34
use std::fs::DirEntry;
4-
use std::os::unix::ffi::OsStrExt;
5+
use std::path::{Path, PathBuf};
56

6-
use crate::project_tree::constants::{EXCLUDED_ROOTS, HIDDEN_PREFIX};
7+
use crate::project_tree::constants::{EXCLUDED_ROOTS, HIDDEN_PREFIX, POSIX_SEPARATOR};
78
use crate::project_tree::models::EntryKind;
89

910
/// How the walk treats one directory entry, without following links.
@@ -17,12 +18,24 @@ pub(crate) fn kind(entry: &DirEntry) -> EntryKind {
1718

1819
/// Whether the walk lists and enters a directory entry.
1920
pub(crate) fn listed_directory(entry: &DirEntry, at_root: bool) -> bool {
20-
let name = entry.file_name();
21-
if name.as_bytes().first() == Some(&HIDDEN_PREFIX) {
21+
let name: OsString = entry.file_name();
22+
let name: &[u8] = name.as_encoded_bytes();
23+
if name.first() == Some(&HIDDEN_PREFIX) {
2224
return false;
2325
}
2426
!(at_root
2527
&& EXCLUDED_ROOTS
2628
.iter()
27-
.any(|excluded| name.as_bytes() == excluded.as_bytes()))
29+
.any(|excluded| name == excluded.as_bytes()))
30+
}
31+
32+
/// The project-relative `as_posix()` spelling of `name` inside `relative`.
33+
pub(crate) fn posix_child(relative: &Path, name: &OsStr) -> PathBuf {
34+
if relative.as_os_str().is_empty() {
35+
return PathBuf::from(name);
36+
}
37+
let mut child: OsString = relative.as_os_str().to_os_string();
38+
child.push(POSIX_SEPARATOR);
39+
child.push(name);
40+
PathBuf::from(child)
2841
}

‎crates/sqlbuild-rules/src/project_tree/_helpers/files.rs‎

Lines changed: 84 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,94 @@
11
//! Resolution of one project-relative path to a real, readable project file.
22
3-
use std::ffi::OsStr;
4-
use std::os::unix::ffi::OsStrExt;
53
use std::path::{Path, PathBuf};
64

7-
use crate::project_tree::constants::MISSING_FILE_ERRNOS;
5+
use crate::project_tree::_helpers::relative_paths::{pure_parts, pure_relative_to};
6+
use crate::project_tree::constants::MISSING_FILE_ERRORS;
87
use crate::project_tree::errors::ProjectTreeFileError;
98
use crate::project_tree::main::tracked_text_path::tracked_text_path;
9+
use crate::project_tree::models::PureParts;
1010

11-
/// `root` joined with the raw path components.
12-
pub(crate) fn joined(root: &Path, parts: &[&[u8]]) -> PathBuf {
11+
/// `root / path` for a relative POSIX path: the root joined with each component.
12+
#[cfg(unix)]
13+
pub(crate) fn joined(root: &Path, parts: &PureParts) -> PathBuf {
1314
let mut path: PathBuf = root.to_path_buf();
14-
path.extend(parts.iter().map(|part| OsStr::from_bytes(part)));
15+
path.extend(&parts.tail);
1516
path
1617
}
1718

19+
/// `root / path` as `ntpath.join` builds it, keeping or replacing the root's drive.
20+
#[cfg(not(unix))]
21+
pub(crate) fn joined(root: &Path, parts: &PureParts) -> PathBuf {
22+
use std::ffi::OsString;
23+
use std::path::MAIN_SEPARATOR_STR;
24+
25+
use crate::project_tree::_helpers::relative_paths::same_part;
26+
27+
let anchor: &str = &parts.anchor.to_string_lossy();
28+
let drive: &str = anchor.trim_end_matches(MAIN_SEPARATOR_STR);
29+
let root_anchor: OsString = pure_parts(root).anchor;
30+
let root_anchor: &str = &root_anchor.to_string_lossy();
31+
let root_drive: &str = root_anchor.trim_end_matches(MAIN_SEPARATOR_STR);
32+
let mut path: PathBuf = if drive.len() < anchor.len() {
33+
let drive: &str = if drive.is_empty() { root_drive } else { drive };
34+
PathBuf::from(format!("{drive}{MAIN_SEPARATOR_STR}"))
35+
} else if drive.is_empty() || same_part(drive.as_ref(), root_drive.as_ref()) {
36+
root.components().collect()
37+
} else {
38+
PathBuf::from(drive)
39+
};
40+
path.extend(&parts.tail);
41+
path
42+
}
43+
44+
/// `path.resolve()` of an existing path, spelled without Windows' verbatim `\\?\` prefix.
45+
#[cfg(unix)]
46+
pub(crate) fn real_path(path: &Path) -> std::io::Result<PathBuf> {
47+
std::fs::canonicalize(path)
48+
}
49+
50+
/// `path.resolve()` of an existing path, spelled without Windows' verbatim `\\?\` prefix.
51+
#[cfg(not(unix))]
52+
pub(crate) fn real_path(path: &Path) -> std::io::Result<PathBuf> {
53+
use std::ffi::OsString;
54+
use std::path::{Component, Prefix};
55+
56+
let real: PathBuf = std::fs::canonicalize(path)?;
57+
let mut components = real.components();
58+
let Some(Component::Prefix(prefix)) = components.next() else {
59+
return Ok(real);
60+
};
61+
let anchor: OsString = match prefix.kind() {
62+
Prefix::VerbatimDisk(drive) => OsString::from(format!("{}:", char::from(drive))),
63+
Prefix::VerbatimUNC(server, share) => {
64+
let mut anchor: OsString = OsString::from(r"\\");
65+
anchor.push(server);
66+
anchor.push(r"\");
67+
anchor.push(share);
68+
anchor
69+
}
70+
_ => return Ok(real),
71+
};
72+
let mut plain: PathBuf = PathBuf::from(anchor);
73+
plain.extend(components);
74+
Ok(plain)
75+
}
76+
77+
/// `path.is_relative_to(root)`, case-folded on Windows as `pathlib` compares there.
78+
pub(crate) fn inside(path: &Path, root: &Path) -> bool {
79+
if cfg!(unix) {
80+
path.starts_with(root)
81+
} else {
82+
pure_relative_to(&pure_parts(path), &pure_parts(root)).is_some()
83+
}
84+
}
85+
1886
/// `Path.is_file()`: false for the failures `pathlib` ignores, the failure otherwise.
1987
pub(crate) fn points_to_file(path: &Path) -> Result<bool, ProjectTreeFileError> {
2088
match std::fs::metadata(path) {
2189
Ok(metadata) => Ok(metadata.is_file()),
2290
Err(error) => match error.raw_os_error() {
23-
Some(errno) if !MISSING_FILE_ERRNOS.contains(&errno) => {
91+
Some(errno) if !MISSING_FILE_ERRORS.contains(&errno) => {
2492
Err(ProjectTreeFileError::Unreadable {
2593
path: path.to_path_buf(),
2694
errno,
@@ -32,16 +100,20 @@ pub(crate) fn points_to_file(path: &Path) -> Result<bool, ProjectTreeFileError>
32100
}
33101

34102
/// A regular, non-link file under the project's resolved parent directory of the path.
35-
pub(crate) fn regular_file(root: &Path, parts: &[&[u8]]) -> Option<PathBuf> {
36-
let (name, parent) = parts.split_last()?;
37-
let directory: PathBuf = match std::fs::canonicalize(joined(root, parent)) {
103+
pub(crate) fn regular_file(root: &Path, parts: &PureParts) -> Option<PathBuf> {
104+
let (name, parent) = parts.tail.split_last()?;
105+
let parent: PureParts = PureParts {
106+
anchor: parts.anchor.clone(),
107+
tail: parent.to_vec(),
108+
};
109+
let directory: PathBuf = match real_path(&joined(root, &parent)) {
38110
Ok(directory) => directory,
39111
Err(_unresolved) => return None,
40112
};
41-
if !directory.starts_with(root) {
113+
if !inside(&directory, root) {
42114
return None;
43115
}
44-
let candidate: PathBuf = directory.join(OsStr::from_bytes(name));
116+
let candidate: PathBuf = directory.join(name);
45117
match std::fs::symlink_metadata(&candidate) {
46118
Ok(metadata) => metadata.file_type().is_file().then_some(candidate),
47119
Err(_unreadable) => None,

0 commit comments

Comments
 (0)