Skip to content

Commit 73439b3

Browse files
committed
commit changes so far
1 parent 6487b21 commit 73439b3

18 files changed

Lines changed: 728 additions & 47 deletions

crates/socket-patch-core/src/crawlers/composer_crawler.rs

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -236,10 +236,14 @@ async fn get_composer_home() -> Option<PathBuf> {
236236
}
237237
}
238238

239-
// Platform defaults
239+
// Platform defaults. A set-but-empty HOME counts as unset: honoring
240+
// `""` would turn the `.composer`/`.config/composer` probes below into
241+
// CWD-relative paths inside the user's project (same rule as
242+
// `utils::fs::home_dir`).
240243
let home_dir = std::env::var("HOME")
241-
.or_else(|_| std::env::var("USERPROFILE"))
242-
.ok()?;
244+
.ok()
245+
.filter(|h| !h.is_empty())
246+
.or_else(|| std::env::var("USERPROFILE").ok().filter(|h| !h.is_empty()))?;
243247
let home = PathBuf::from(home_dir);
244248

245249
let candidates = [

crates/socket-patch-core/src/crawlers/go_crawler.rs

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -248,9 +248,14 @@ impl GoCrawler {
248248
return Some(first.join("pkg").join("mod"));
249249
}
250250
}
251+
// A set-but-empty HOME/USERPROFILE counts as unset, matching the
252+
// GOMODCACHE and GOPATH guards above: honoring `""` would yield the
253+
// RELATIVE path `go/pkg/mod`, pointing the crawl at a directory
254+
// inside the user's project instead of a real module cache.
251255
let home = std::env::var("HOME")
252-
.or_else(|_| std::env::var("USERPROFILE"))
253-
.ok()?;
256+
.ok()
257+
.filter(|h| !h.is_empty())
258+
.or_else(|| std::env::var("USERPROFILE").ok().filter(|h| !h.is_empty()))?;
254259
Some(PathBuf::from(home).join("go").join("pkg").join("mod"))
255260
}
256261

crates/socket-patch-core/src/crawlers/npm_crawler.rs

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,18 @@ struct PackageJsonPartial {
3232

3333
/// Read and parse a `package.json` file, returning `(name, version)` if valid.
3434
pub async fn read_package_json(pkg_json_path: &Path) -> Option<(String, String)> {
35-
let content = tokio::fs::read_to_string(pkg_json_path).await.ok()?;
35+
use tokio::io::AsyncReadExt;
36+
37+
// The path lives inside the (untrusted) package tree: a planted FIFO
38+
// would make a plain `read_to_string` open block forever waiting for a
39+
// writer, wedging scan (crawl_all) and apply (find_by_purls). Open via
40+
// `open_regular_file` — non-blocking on Unix, rejecting
41+
// FIFOs/devices/directories (see its docs).
42+
let (mut file, metadata) = crate::utils::fs::open_regular_file(pkg_json_path)
43+
.await
44+
.ok()?;
45+
let mut content = String::with_capacity(metadata.len() as usize);
46+
file.read_to_string(&mut content).await.ok()?;
3647
// npm and Node both tolerate a leading UTF-8 BOM in package.json
3748
// (Windows-authored packages ship them), but serde_json rejects it —
3849
// a BOM'd install would be invisible to scan and unpatchable.

crates/socket-patch-core/src/crawlers/python_crawler.rs

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -424,6 +424,10 @@ pub async fn get_global_python_site_packages() -> Vec<PathBuf> {
424424
// uv tools — platform-specific install root.
425425
#[cfg(target_os = "macos")]
426426
{
427+
// Legacy/secondary location only: uv follows XDG conventions on
428+
// macOS (`uv tool dir` → ~/.local/share/uv/tools, covered by the
429+
// not(windows) scan below), but older layouts used the platform
430+
// data dir, so keep scanning it too.
427431
let uv_base = home_dir
428432
.join("Library")
429433
.join("Application Support")
@@ -446,8 +450,11 @@ pub async fn get_global_python_site_packages() -> Vec<PathBuf> {
446450
}
447451
}
448452
}
449-
#[cfg(all(not(target_os = "macos"), not(windows)))]
453+
#[cfg(not(windows))]
450454
{
455+
// uv uses XDG paths on BOTH Linux and macOS (`uv tool dir` →
456+
// ~/.local/share/uv/tools; verified against a real uv install —
457+
// macOS does NOT get an Application Support tool dir).
451458
let uv_base = home_dir
452459
.join(".local")
453460
.join("share")
@@ -509,13 +516,18 @@ pub async fn get_global_python_site_packages() -> Vec<PathBuf> {
509516
/// * `requirements.txt` — pip-compile / bare requirements
510517
/// * `uv.lock` — uv-managed projects (PEP 751 export sibling is
511518
/// `pylock.toml` but in practice `uv.lock` is what ships)
519+
/// * `Pipfile` / `Pipfile.lock` — pipenv projects, which commonly
520+
/// ship NEITHER pyproject.toml nor requirements.txt and keep
521+
/// their venvs out-of-tree (`~/.local/share/virtualenvs`)
512522
pub async fn is_python_project(cwd: &Path) -> bool {
513523
let markers = [
514524
"pyproject.toml",
515525
"setup.py",
516526
"setup.cfg",
517527
"requirements.txt",
518528
"uv.lock",
529+
"Pipfile",
530+
"Pipfile.lock",
519531
];
520532
for m in &markers {
521533
if tokio::fs::metadata(cwd.join(m)).await.is_ok() {
@@ -614,11 +626,16 @@ impl PythonCrawler {
614626
) -> Result<HashMap<String, CrawledPackage>, std::io::Error> {
615627
let mut result = HashMap::new();
616628

617-
// Build lookup: canonicalized-name@version -> purl
629+
// Build lookup: canonicalized-name@version -> purl. The API serves
630+
// purls percent-encoded (a PEP 440 local/epoch version carries
631+
// `+`/`!`, arriving as `%2B`/`%21`), so decode the coordinates
632+
// before keying or the installed package never matches.
618633
let mut purl_lookup: HashMap<String, &str> = HashMap::new();
619634
for purl in purls {
620635
if let Some((name, version)) = crate::utils::purl::parse_pypi_purl(purl) {
621-
let key = format!("{}@{}", canonicalize_pypi_name(name), version);
636+
let name = crate::utils::purl::percent_decode_purl_component(name);
637+
let version = crate::utils::purl::percent_decode_purl_component(version);
638+
let key = format!("{}@{}", canonicalize_pypi_name(&name), version);
622639
purl_lookup.insert(key, purl.as_str());
623640
}
624641
}

crates/socket-patch-core/src/patch/rollback.rs

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,25 @@ pub async fn verify_file_rollback(
120120
}
121121
Ok(_) => {}
122122
}
123-
let current_hash = compute_file_git_sha256(&filepath).await.unwrap_or_default();
123+
// A hash failure (directory/FIFO planted at the path, unreadable
124+
// file, dangling symlink target) is the same unverifiable state as a
125+
// stat failure above and must fail closed with the real error — a
126+
// swallowed error would misreport "modified after patching" with a
127+
// fabricated empty hash, and would compare equal to an empty
128+
// `after_hash`, wrongly clearing the entry for deletion.
129+
let current_hash = match compute_file_git_sha256(&filepath).await {
130+
Ok(h) => h,
131+
Err(e) => {
132+
return VerifyRollbackResult {
133+
file: file_name.to_string(),
134+
status: VerifyRollbackStatus::NotFound,
135+
message: Some(format!("Failed to hash file: {}", e)),
136+
current_hash: None,
137+
expected_hash: None,
138+
target_hash: None,
139+
};
140+
}
141+
};
124142
if current_hash == file_info.after_hash {
125143
return VerifyRollbackResult {
126144
file: file_name.to_string(),

crates/socket-patch-core/src/pth_hook/edit.rs

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -189,11 +189,30 @@ fn pyproject_add(content: &str) -> Result<Option<String>, String> {
189189
.and_then(|t| t.get("poetry"))
190190
.and_then(Item::as_table)
191191
.is_some();
192+
// PEP 621 forbids a field that is both listed in `dynamic` and set
193+
// statically, so a project with `dynamic = ["dependencies"]` (setuptools/
194+
// hatch dynamic metadata, or Poetry 2.x keeping its dependency surface in
195+
// `[tool.poetry.dependencies]`) must not gain a static array — every
196+
// backend would refuse to build the manifest.
197+
let dynamic_deps = doc
198+
.get("project")
199+
.and_then(Item::as_table)
200+
.and_then(|t| t.get("dynamic"))
201+
.and_then(Item::as_array)
202+
.map(|a| a.iter().any(|v| v.as_str() == Some("dependencies")))
203+
.unwrap_or(false);
192204

193-
let changed = if has_poetry && !real_pep621 {
205+
let changed = if has_poetry && (!real_pep621 || dynamic_deps) {
194206
poetry_add(&mut doc)?
195-
} else if real_pep621 {
207+
} else if real_pep621 && !dynamic_deps {
196208
pep621_add(&mut doc)?
209+
} else if dynamic_deps {
210+
return Err(
211+
"pyproject.toml declares `[project].dependencies` as dynamic; adding a static \
212+
dependencies array would make the manifest invalid — declare the hook in the \
213+
source the dynamic metadata is resolved from (or use requirements.txt) instead"
214+
.to_string(),
215+
);
197216
} else {
198217
// Neither surface exists (e.g. a `[build-system]`-only or tool-config-only
199218
// pyproject.toml of a setup.py/setup.cfg project). Synthesizing a
@@ -948,7 +967,12 @@ mod tests {
948967
.unwrap();
949968
let res = remove_hook_dependency(&req, ManifestKind::Requirements, false).await;
950969
assert_eq!(res.status, PthStatus::Updated);
951-
let mode = tokio::fs::metadata(&req).await.unwrap().permissions().mode() & 0o777;
970+
let mode = tokio::fs::metadata(&req)
971+
.await
972+
.unwrap()
973+
.permissions()
974+
.mode()
975+
& 0o777;
952976
assert_eq!(mode, 0o744, "remove must not reset requirements.txt mode");
953977
}
954978

crates/socket-patch-core/src/utils/fs.rs

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -148,16 +148,21 @@ pub(crate) async fn entry_file_type(entry: &DirEntry) -> Option<FileType> {
148148

149149
/// Resolve the user's home directory: `HOME`, then `USERPROFILE`
150150
/// (Windows), then a literal `"~"` — a harmless non-existent path so
151-
/// downstream joins probe nothing rather than panic. The shared
151+
/// downstream joins probe nothing rather than panic. A set-but-empty
152+
/// variable counts as unset: honoring `""` would turn every
153+
/// `home_dir().join(…)` probe into a CWD-relative path, pointing the
154+
/// crawlers at directories inside the user's project. The shared
152155
/// fallback chain for every crawler that scans well-known per-user
153156
/// package roots (`~/.cargo`, `~/.m2`, `~/.nuget`, …) and for
154157
/// telemetry's home-dir redaction; previously copy-pasted into each.
155158
/// The go/composer crawlers deliberately use a stricter
156159
/// no-home-means-no-path chain instead.
157160
pub(crate) fn home_dir() -> PathBuf {
158161
let home = std::env::var("HOME")
159-
.or_else(|_| std::env::var("USERPROFILE"))
160-
.unwrap_or_else(|_| "~".to_string());
162+
.ok()
163+
.filter(|h| !h.is_empty())
164+
.or_else(|| std::env::var("USERPROFILE").ok().filter(|h| !h.is_empty()))
165+
.unwrap_or_else(|| "~".to_string());
161166
PathBuf::from(home)
162167
}
163168

@@ -474,6 +479,35 @@ mod tests {
474479
assert_eq!(tokio::fs::read(&fresh).await.unwrap(), b"x");
475480
}
476481

482+
/// Regression: a set-but-empty `HOME` (stripped CI/container/sudo
483+
/// environments) must be treated as unset, exactly like the documented
484+
/// no-home fallback. Honoring `""` made `home_dir()` return an empty
485+
/// `PathBuf`, so every `home_dir().join(".cargo")`-style probe became a
486+
/// CWD-relative path and the crawlers scanned directories inside the
487+
/// user's project as if they were the per-user package roots.
488+
#[test]
489+
#[serial_test::serial]
490+
fn home_dir_treats_empty_home_as_unset() {
491+
let prev_home = std::env::var("HOME").ok();
492+
let prev_profile = std::env::var("USERPROFILE").ok();
493+
std::env::set_var("HOME", "");
494+
std::env::set_var("USERPROFILE", "");
495+
let home = home_dir();
496+
match prev_home {
497+
Some(v) => std::env::set_var("HOME", v),
498+
None => std::env::remove_var("HOME"),
499+
}
500+
match prev_profile {
501+
Some(v) => std::env::set_var("USERPROFILE", v),
502+
None => std::env::remove_var("USERPROFILE"),
503+
}
504+
assert_eq!(
505+
home,
506+
PathBuf::from("~"),
507+
"empty HOME/USERPROFILE must fall back to the harmless `~` sentinel"
508+
);
509+
}
510+
477511
/// `entry_file_type` is the symlink-aware counterpart: it reports
478512
/// the link itself (`is_symlink`), never the resolved target.
479513
#[cfg(unix)]

crates/socket-patch-core/src/utils/telemetry.rs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,10 +177,16 @@ pub fn sanitize_error_message(message: &str) -> String {
177177
// `home_dir()` falls back to a literal `"~"` when no home is set, and
178178
// replacing `"~"` with `"~"` is a no-op. A set-but-empty HOME must be
179179
// skipped explicitly — replacing `""` would splice `~` between every byte.
180+
// Trailing separators are trimmed so a `HOME=/home/user/` redaction keeps
181+
// the separator (`~/.cache`, not `~.cache`); a home that trims to nothing
182+
// (`HOME=/`, common for unmapped-UID containers) is a filesystem root with
183+
// no user-identifying prefix to redact — replacing it would splice `~`
184+
// between every path segment in the message.
185+
let home = home.trim_end_matches(['/', '\\']);
180186
if home.is_empty() {
181187
return message.to_string();
182188
}
183-
message.replace(home.as_ref(), "~")
189+
message.replace(home, "~")
184190
}
185191

186192
/// Build a telemetry event. `error` is an `(error_type, message)` pair; the

0 commit comments

Comments
 (0)