Skip to content
Open
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
11 changes: 10 additions & 1 deletion crates/socket-patch-cli/src/commands/composer_hints.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,10 @@ const VENDOR_PREFIX: &str = ".socket/vendor/composer/";

/// The `<vendor>/<name>` of every composer.lock package (`packages` and
/// `packages-dev`) whose dist is a vendored `path` copy, sorted and
/// de-duplicated. An unparseable lock yields nothing.
/// de-duplicated. An unparseable lock yields nothing; one leading BOM is
/// read past, as the vendored backend reads the lock.
pub(crate) fn vendored_composer_packages(lock_text: &str) -> Vec<String> {
let lock_text = socket_patch_core::formats::text::strip_bom(lock_text);
let Ok(lock) = serde_json::from_str::<Value>(lock_text) else {
return Vec::new();
};
Expand Down Expand Up @@ -182,6 +184,13 @@ mod tests {
);
assert!(vendored_composer_packages("not json").is_empty());
assert!(vendored_composer_packages("{}").is_empty());
// One leading BOM is encoding (the backend reads past it); a second
// is content.
assert_eq!(
vendored_composer_packages(&format!("\u{feff}{}", lock())),
vendored_composer_packages(&lock())
);
assert!(vendored_composer_packages(&format!("\u{feff}\u{feff}{}", lock())).is_empty());
}

#[test]
Expand Down
58 changes: 58 additions & 0 deletions crates/socket-patch-cli/src/commands/vendor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2732,6 +2732,13 @@ async fn sweep_stale_artifact(
);
}

/// Whether the lock (or, for a vendored package, the ledger's pre-vendor
/// fragment) resolves `purl` with a content verifier: the yes/no gate
/// [`lock_refusals_reaching_backend`] reads. It downloads nothing — the
/// vendored backends take their bytes from the patch service by patch
/// uuid — so a Socket-hosted entry's patched URL and hash are never used
/// as a pristine source here (the callers leave hosted-pinned purls to the
/// takeover, which restores the upstream entry from the registry).
pub(crate) async fn pristine_fetch_is_verifiable(
project_root: &Path,
inventory: &[lock_inventory::LockfileEntry],
Expand Down Expand Up @@ -8153,3 +8160,54 @@ mod unused_vendored_manifest_keys_tests {
);
}
}

/// The CLI side of core's `lock_pinned_locations_never_drive_a_download`
/// (see its docs for what the check does and does not catch). Core's
/// registry downloads (`registry_fetch::download`,
/// `maven_repo::fetch_registry_bytes`) are `pub(crate)`, so the compiler
/// already keeps the CLI from calling them (a by-name scan here would
/// only meet the CLI's own `download` locals). What is pinned is the
/// public `recover_lock_entry`: its one CLI reference is
/// [`pristine_fetch_is_verifiable`]'s yes/no gate.
#[cfg(test)]
mod lock_download_guard_tests {
use socket_patch_core::vendor::test_support::production_sources::{
production_sources, references,
};

const PINNED_REFERENCES: &[(&str, &str, &str)] = &[(
"commands/vendor.rs",
"recover_lock_entry",
"Some(le) => lock_inventory::recover_lock_entry(project_root, le)",
)];

#[test]
fn lock_pinned_locations_never_drive_a_download() {
let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
let mut found: Vec<(String, &str, String)> = Vec::new();
for file in production_sources(&src) {
let code = file.code();
let lines: Vec<&str> = file.text.lines().collect();
for (line, _) in references(&code, "recover_lock_entry") {
found.push((
file.rel.clone(),
"recover_lock_entry",
lines[line - 1].trim().to_string(),
));
}
}
found.sort();
let mut expected: Vec<(String, &str, String)> = PINNED_REFERENCES
.iter()
.map(|(rel, name, line)| (rel.to_string(), *name, line.to_string()))
.collect();
expected.sort();
assert_eq!(
found, expected,
"a production reference to `recover_lock_entry` was added, removed or changed. \
Its recovered `resolved`/`integrity` may be a Socket-hosted (patched) artifact's: \
never download them as a pristine source; re-check the site, then update \
PINNED_REFERENCES"
);
}
}
12 changes: 10 additions & 2 deletions crates/socket-patch-core/src/crawlers/composer_crawler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ impl InstallerLayout {
let root = crate::utils::fs::read_regular_to_string(&project_root.join("composer.json"))
.await
.ok()
.and_then(|content| serde_json::from_str::<serde_json::Value>(&content).ok());
.and_then(|content| crate::vendor::common::parse_json_text(&content).ok());
let extra = root.as_ref().and_then(|r| r.get("extra"));
Self {
active: true,
Expand Down Expand Up @@ -630,7 +630,7 @@ const PROJECT_ROOT_SEARCH_DEPTH: usize = 3;
/// value counts as unset. Placeholder expansion is the caller's call, see
/// [`resolve_vendor_dir`].
fn parse_config_vendor_dir(composer_json: &str) -> Option<String> {
let doc: serde_json::Value = serde_json::from_str(composer_json).ok()?;
let doc: serde_json::Value = crate::vendor::common::parse_json_text(composer_json).ok()?;
let raw = doc.get("config")?.get("vendor-dir")?.as_str()?;
let trimmed = raw.trim_end_matches(['/', '\\']);
(!trimmed.is_empty()).then(|| trimmed.to_string())
Expand Down Expand Up @@ -1964,6 +1964,14 @@ mod tests {
None
);
assert_eq!(parse_config_vendor_dir("{ not json"), None);
// One leading BOM is encoding; a second is content.
let bom = crate::formats::text::BOM;
let json = r#"{"config":{"vendor-dir":"lib/deps"}}"#;
assert_eq!(
parse_config_vendor_dir(&format!("{bom}{json}")).as_deref(),
Some("lib/deps")
);
assert_eq!(parse_config_vendor_dir(&format!("{bom}{bom}{json}")), None);
}

/// Composer's `Platform::expandPath`: only a LEADING `~/`, `$VAR` or
Expand Down
44 changes: 7 additions & 37 deletions crates/socket-patch-core/src/formats/text.rs
Original file line number Diff line number Diff line change
Expand Up @@ -105,49 +105,19 @@ mod tests {
"\\xef\\xbb\\xbf",
"\\xEF\\xBB\\xBF",
];
fn walk(dir: &std::path::Path, out: &mut Vec<std::path::PathBuf>) {
for entry in std::fs::read_dir(dir).unwrap() {
let path = entry.unwrap().path();
if path.is_dir() {
walk(&path, out);
} else if path.extension().is_some_and(|e| e == "rs") {
out.push(path);
}
}
}
let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
let mut files = Vec::new();
walk(&src, &mut files);
let mut inline = Vec::new();
for path in files {
let rel = path
.strip_prefix(&src)
.unwrap()
.to_string_lossy()
.replace('\\', "/");
// Test-only files and items are left out by the shared reader.
for file in crate::vendor::test_support::production_sources::production_sources(&src) {
let rel = file.rel.as_str();
if rel == "formats/text.rs"
|| rel.ends_with("tests.rs")
|| rel.contains("test_support")
|| PENDING_INLINE_BOMS.contains(&rel.as_str())
|| OWN_BOM_RULE.contains(&rel.as_str())
|| PENDING_INLINE_BOMS.contains(&rel)
|| OWN_BOM_RULE.contains(&rel)
{
continue;
}
// Windows CI checks out with CRLF.
let text = std::fs::read_to_string(&path)
.unwrap()
.replace("\r\n", "\n");
let production = [
"#[cfg(test)]\nmod tests",
"#[cfg(test)]\npub(crate) mod tests",
"#[cfg(test)]\nmod architecture_tests",
]
.iter()
.filter_map(|marker| text.find(marker))
.min()
.map_or(text.as_str(), |at| &text[..at]);
if INLINE.iter().any(|p| production.contains(p)) {
inline.push(rel);
if INLINE.iter().any(|p| file.text.contains(p)) {
inline.push(file.rel);
}
}
inline.sort();
Expand Down
19 changes: 17 additions & 2 deletions crates/socket-patch-core/src/patch/redirect/upstream/composer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ use crate::formats::composer::hosted::{
find_composer_entry, find_composer_member, json_string_field, ComposerEntry,
};
use crate::utils::redact::url_hostname;
use crate::vendor::common::parse_json_text;

const COMPOSER_LOCK: &str = "composer.lock";
/// The `notification-url` composer records for packagist packages.
Expand Down Expand Up @@ -119,7 +120,7 @@ fn declares_custom_repositories(composer_json: Option<&str>) -> Result<bool, Str
return Ok(false);
};
let doc: Value =
serde_json::from_str(text).map_err(|e| format!("composer.json is not JSON: {e}"))?;
parse_json_text(text).map_err(|e| format!("composer.json is not JSON: {e}"))?;
Ok(match doc.get("repositories") {
None | Some(Value::Null) => false,
Some(Value::Array(a)) => !a.is_empty(),
Expand All @@ -137,7 +138,7 @@ fn declares_custom_repositories(composer_json: Option<&str>) -> Result<bool, Str
/// back). Options on another repository belong to that repository's
/// entries, which the restore refuses.
fn packagist_declares_options(composer_json: Option<&str>) -> bool {
let Some(doc) = composer_json.and_then(|t| serde_json::from_str::<Value>(t).ok()) else {
let Some(doc) = composer_json.and_then(|t| parse_json_text(t).ok()) else {
return false;
};
let packagist_with_options = |r: &Value| {
Expand Down Expand Up @@ -495,6 +496,14 @@ mod tests {
Ok(true)
);
assert!(declares_custom_repositories(Some("{")).is_err());
// One leading BOM is encoding, as composer.lock's readers treat it.
let bom = crate::formats::text::BOM;
assert_eq!(
declares_custom_repositories(Some(&format!(
"{bom}{{\"repositories\": [{{\"type\": \"vcs\", \"url\": \"https://g.example/x\"}}]}}"
))),
Ok(true)
);
}

#[test]
Expand Down Expand Up @@ -522,6 +531,12 @@ mod tests {
r#"{"repositories": [{"type": "composer", "url": "https://packagist.org.evil.example",
"options": {"http": {"header": ["X-Token: t"]}}}]}"#
)));
let bom = crate::formats::text::BOM;
assert!(packagist_declares_options(Some(&format!(
"{bom}{}",
r#"{"repositories": [{"type": "composer", "url": "https://repo.packagist.org",
"options": {"http": {"proxy": "http://proxy:3128"}}}]}"#
))));
}

#[test]
Expand Down
Loading
Loading