diff --git a/crates/socket-patch-core/src/hosted/engine.rs b/crates/socket-patch-core/src/hosted/engine.rs index 09901663b..d163abf71 100644 --- a/crates/socket-patch-core/src/hosted/engine.rs +++ b/crates/socket-patch-core/src/hosted/engine.rs @@ -487,64 +487,63 @@ async fn read_advisory( } } +/// An in-memory project's Go files as the hosted engine reads them +/// ([`read_advisory`]: a link or an entry without content is unreadable, +/// but still exists). +struct AdvisoryGoFiles<'v, 'p> { + view: &'v ProjectView<'p>, + unreadable: &'v BTreeSet, +} + +impl crate::vendor::go_workspace::MemoryFiles for AdvisoryGoFiles<'_, '_> { + async fn exists(&mut self, rel: &str) -> bool { + self.unreadable.contains(rel) || self.view.exists_no_follow(rel).await + } + + async fn read(&mut self, rel: &str) -> Option { + read_advisory(self.view, self.unreadable, rel).await + } + + async fn read_sum(&mut self, rel: &str) -> Option { + read_advisory(self.view, self.unreadable, rel).await + } + + fn unparseable(&mut self, _rel: &str, _problem: &str) {} +} + /// The Go workspace in effect for the project, handed to the golang /// rewriter under [`crate::patch::redirect::GO_WORKSPACE_KEY`]. On disk it is /// the one go resolves (`GOWORK`, else a go.work at or above the root, with /// members anywhere, plus a `-modfile` that GOFLAGS sets); in memory a root -/// `go.work` and its in-root members. A root go.work and go.work.sum are candidate files the rewriter writes. +/// `go.work` and its in-root members, read as discovery reads them +/// ([`crate::vendor::go_workspace::read_memory`]), whose paths every check +/// compares lexically, never probing the disk or the process's working +/// directory. A root go.work and go.work.sum are candidate files the +/// rewriter writes. async fn read_go_workspace( view: &ProjectView<'_>, unreadable: &BTreeSet, out: &mut CandidateFiles, ) { - use crate::vendor::go_workspace::{self, GoWorkspace, Loaded, Member}; + use crate::vendor::go_workspace::{self, Loaded}; let (root, loaded, modfile, module_graph) = match view { - ProjectView::Memory(_) => 'memory: { + ProjectView::Memory(_) => { let Some(work) = read_advisory(view, unreadable, "go.work").await else { return; }; let work = crate::vendor::go_mod_edit::normalize_for_read(&work); // As on disk (`go_workspace::load`): a go.work go cannot parse // is a workspace go refuses to build, never module mode. - if let Some(problem) = crate::vendor::go_mod_edit::block_structure_error(&work) { - break 'memory ( - std::path::PathBuf::new(), - Loaded::Invalid(format!("go.work: {problem}, so go refuses to build")), - None, - None, - ); - } - let mut members = Vec::new(); - for raw in crate::vendor::go_mod_edit::parse_use_dirs(&work) { - let Some(dir) = go_workspace::in_root_dir(&raw) else { - members.push(Member { - dir: raw.into(), - go_mod: None, - go_sum: None, - }); - continue; - }; - let at = |file: &str| match dir.as_str() { - "" => file.to_string(), - dir => format!("{dir}/{file}"), - }; - members.push(Member { - go_mod: read_advisory(view, unreadable, &at("go.mod")) - .await - .map(|t| crate::vendor::go_mod_edit::normalize_for_read(&t)), - go_sum: read_advisory(view, unreadable, &at("go.sum")).await, - dir: dir.into(), - }); - } - let work_sum = read_advisory(view, unreadable, "go.work.sum").await; - let ws = GoWorkspace { - work_file: "go.work".into(), - work, - work_sum, - members, - graph: None, + let loaded = match crate::vendor::go_mod_edit::block_structure_error(&work) { + Some(problem) => { + Loaded::Invalid(format!("go.work: {problem}, so go refuses to build")) + } + None => { + let mut files = AdvisoryGoFiles { view, unreadable }; + Loaded::Workspace(go_workspace::read_memory(&work, &mut files).await.0) + } }; - (std::path::PathBuf::new(), Loaded::Workspace(ws), None, None) + (std::path::PathBuf::new(), loaded, None, None) } ProjectView::Disk(_) | ProjectView::Snapshot(_) => { let Some(root) = view.disk_root_reading(std::iter::empty::<&std::path::Path>()) else { @@ -3691,6 +3690,120 @@ mod tests { ); } + /// The in-memory engine's workspace is judged lexically, from the + /// project's own files: a root module go.work doesn't `use` is "not a + /// member", whatever the process's working directory holds. The disk + /// checks probed `/go.mod` instead, so with this crate's directory + /// as the cwd (no go.mod) the rewriter called the root module-less + /// ("nowhere to go"), and with a go.mod there it gave the member answer. + /// A root go.mod go cannot parse, or one that cannot be read (a link), + /// still exists: the same files, the same verdict, as discovery's + /// `in_memory_workspace_files_are_the_files_that_exist` finds. + #[tokio::test] + async fn memory_go_workspace_never_probes_the_cwd() { + use crate::patch::redirect::{ + rewrite_registry_redirect, HostedGoWorkspace, Integrity, RegistryOverride, + RegistryOverrideIdentifiers, GO_WORKSPACE_KEY, + }; + use crate::vendor::go_workspace::{Loaded, WorkspaceConflict}; + const MODULE: &str = "github.com/foo/bar"; + let socket = format!("patch.socket.dev/gopatch/{PATCH_UUID}"); + let hash = format!("h1:{}=", "A".repeat(43)); + let ovr = DepOverride { + ecosystem: "golang".into(), + name: MODULE.into(), + version: "v1.4.2".into(), + registry_override: Some(RegistryOverride { + kind: "goproxy".into(), + index_url: "https://patch.socket.dev/patch-registry/golang".into(), + identifiers: RegistryOverrideIdentifiers { + name: MODULE.into(), + version: "v1.4.2".into(), + go_module_path: Some(socket.clone()), + go_module_version: Some("v1.4.2-socketpatch.1".into()), + ..Default::default() + }, + }), + integrity: Integrity { + dirhash_h1: Some(hash.clone()), + go_mod_h1: Some(hash), + ..Default::default() + }, + ..grant_dep("golang", String::new()) + }; + let go_mod = format!("module example.com/app\n\ngo 1.21\n\nrequire {MODULE} v1.4.2\n"); + let b = format!("module example.com/b\n\ngo 1.21\n\nrequire {MODULE} v1.4.2\n"); + let work = "go 1.21\n\nuse ./b\n"; + let ours = crate::vendor::go_mod_edit::ReplaceEntry { + module: MODULE.into(), + version: Some("v1.4.2".into()), + path: None, + rhs_module: Some(socket), + rhs_version: Some("v1.4.2-socketpatch.1".into()), + owner: Some(crate::vendor::go_mod_edit::ReplaceOwner::Hosted), + }; + let unparseable = MemoryEntry::Text("module example.com/app\n\nrequire (\n".into()); + for root in [ + MemoryEntry::Text(go_mod.as_str().into()), + unparseable, + MemoryEntry::Symlink, + ] { + let mut p = MemoryProject::new(); + p.insert("go.mod", root.clone()); + p.insert_text("go.work", work); + p.insert_text("b/go.mod", b.as_str()); + let mut out = CandidateFiles::default(); + read_go_workspace(&ProjectView::Memory(&p), &BTreeSet::new(), &mut out).await; + let hosted: HostedGoWorkspace = + serde_json::from_str(&out.files[GO_WORKSPACE_KEY]).unwrap(); + let Loaded::Workspace(ws) = &hosted.loaded else { + panic!("{root:?}: {:?}", hosted.loaded); + }; + assert_eq!( + ws.files, + Some( + ["go.mod", "go.work", "b/go.mod"] + .into_iter() + .map(std::path::PathBuf::from) + .collect() + ), + "{root:?}" + ); + assert!( + matches!( + hosted + .loaded + .conflict(&hosted.root, MODULE, "v1.4.2", &ours), + Some(WorkspaceConflict::NotAMember { .. }) + ), + "{root:?}" + ); + } + // End to end through the rewriter. + let mut p = MemoryProject::new(); + p.insert_text("go.mod", go_mod.as_str()); + p.insert_text("go.work", work); + p.insert_text("b/go.mod", b.as_str()); + let mut read = CandidateFiles::default(); + read_go_workspace(&ProjectView::Memory(&p), &BTreeSet::new(), &mut read).await; + let mut files = read.files; + for (path, text) in [("go.mod", go_mod.as_str()), ("go.work", work)] { + files.insert(path.into(), text.into()); + } + let out = rewrite_registry_redirect(&files, std::slice::from_ref(&ovr)); + assert!(out.files.is_empty(), "{:?}", out.files); + assert!(out.confirmed_golang_uuids.is_empty()); + assert_eq!(out.warnings.len(), 1, "{:?}", out.warnings); + assert_eq!(out.warnings[0].code, "redirect_golang_workspace_conflict"); + assert!( + out.warnings[0] + .detail + .contains("go.work does not `use` this module"), + "{:?}", + out.warnings + ); + } + fn no_cargo_outer() -> CratesIoSource { CratesIoSource::Registry } diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index 0c778ac74..5c35e17b2 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -24245,8 +24245,13 @@ packages: } /// The engine's [`GO_WORKSPACE_KEY`] for an in-memory go.work whose - /// members are `(dir, go.mod)` pairs (`.` for the root module). - fn go_workspace_key(work_extra: &str, members: &[(&str, &str)]) -> (String, String) { + /// members are `(dir, go.mod)` pairs (`.` for the root module), in a + /// project with a root go.mod when `root_go_mod`. + fn go_workspace_key( + work_extra: &str, + members: &[(&str, &str)], + root_go_mod: bool, + ) -> (String, String) { use crate::vendor::go_workspace::{GoWorkspace, Loaded, Member}; let uses: String = members.iter().map(|(d, _)| format!("\t{d}\n")).collect(); let hosted = HostedGoWorkspace { @@ -24278,6 +24283,10 @@ packages: }) .collect(), ), + files: Some(memory_go_files( + members.iter().map(|(dir, _)| *dir), + root_go_mod, + )), }), modfile: None, module_graph: None, @@ -24288,6 +24297,29 @@ packages: ) } + /// The files an in-memory workspace of the `use` members `dirs` holds, + /// as [`crate::vendor::go_workspace::read_memory`] lists them: go.work, + /// the root go.mod when the project has one (`root_go_mod`, whether or + /// not `.` is a member) and each in-root member's go.mod. + fn memory_go_files<'a>( + dirs: impl IntoIterator, + root_go_mod: bool, + ) -> std::collections::BTreeSet { + let mut files = std::collections::BTreeSet::from(["go.work".into()]); + if root_go_mod { + files.insert("go.mod".into()); + } + for dir in dirs { + files.extend(crate::vendor::go_workspace::in_root_dir(dir).map( + |dir| match dir.as_str() { + "" => "go.mod".into(), + dir => format!("{dir}/go.mod").into(), + }, + )); + } + files + } + /// [`go_workspace_key`] plus the root go.work itself as a candidate /// file, as the engine reads it when go.work sits at the root. fn with_root_go_work( @@ -24295,7 +24327,7 @@ packages: work_extra: &str, members: &[(&str, &str)], ) { - let (k, v) = go_workspace_key(work_extra, members); + let (k, v) = go_workspace_key(work_extra, members, files.contains_key("go.mod")); let uses: String = members.iter().map(|(d, _)| format!("\t{d}\n")).collect(); files.insert(k, v); files.insert( @@ -24324,8 +24356,11 @@ packages: ("\nreplace github.com/foo/bar => ./fork\n", ""), ] { let mut files = files.clone(); - let (k, v) = - go_workspace_key(work, &[(".", &root), ("b", &go_member("v1.4.2", other))]); + let (k, v) = go_workspace_key( + work, + &[(".", &root), ("b", &go_member("v1.4.2", other))], + true, + ); files.insert(k, v); let out = rewrite_registry_redirect(&files, std::slice::from_ref(&ovr)); assert!(out.files.is_empty(), "{work}{other}: {:?}", out.files); @@ -24343,6 +24378,7 @@ packages: let (k, v) = go_workspace_key( "\nreplace github.com/foo/bar v1.0.0 => ./fork\n", &[(".", &root), ("b", &go_member("v1.4.2", ""))], + true, ); files.insert(k, v); let out = rewrite_registry_redirect(&files, std::slice::from_ref(&ovr)); @@ -24358,7 +24394,7 @@ packages: let ovr = golang_override(); let mut files = golang_files(); let root = files["go.mod"].clone(); - let (k, v) = go_workspace_key("", &[(".", &root), ("b", &go_member("v1.5.0", ""))]); + let (k, v) = go_workspace_key("", &[(".", &root), ("b", &go_member("v1.5.0", ""))], true); files.insert(k, v); let out = rewrite_registry_redirect(&files, std::slice::from_ref(&ovr)); assert!(out.files.is_empty()); @@ -24368,7 +24404,7 @@ packages: let bare = "module example.com/app\n\ngo 1.21\n"; let mut files = BTreeMap::new(); files.insert("go.mod".to_string(), bare.to_string()); - let (k, v) = go_workspace_key("", &[(".", bare), ("b", &go_member("v1.4.2", ""))]); + let (k, v) = go_workspace_key("", &[(".", bare), ("b", &go_member("v1.4.2", ""))], true); files.insert(k, v); let out = rewrite_registry_redirect(&files, std::slice::from_ref(&ovr)); assert!(out.warnings.is_empty(), "{:?}", out.warnings); @@ -24501,6 +24537,7 @@ packages: member("../out", None), ], graph: None, + files: Some(memory_go_files([".", "../out"], true)), }), modfile: None, module_graph: None, diff --git a/crates/socket-patch-core/src/vendor/go_workspace.rs b/crates/socket-patch-core/src/vendor/go_workspace.rs index 08fb3686d..49f5c4047 100644 --- a/crates/socket-patch-core/src/vendor/go_workspace.rs +++ b/crates/socket-patch-core/src/vendor/go_workspace.rs @@ -156,6 +156,12 @@ pub struct GoWorkspace { /// the module cache has it); `None` until [`GoWorkspace::read_graph`]. #[serde(default)] pub graph: Option>>, + /// An in-memory project's workspace ([`read_memory`]): the + /// root-relative files that exist, so every path check compares paths + /// lexically ([`PathSpace::Memory`]) and none touches the filesystem or + /// the process's working directory. `None` on disk. + #[serde(default)] + pub files: Option>, } /// What MVS reads from a dependency's go.mod. @@ -272,6 +278,7 @@ async fn load_file(work_file: PathBuf) -> Loaded { work_sum, members, graph: None, + files: None, }) } @@ -517,6 +524,7 @@ pub async fn module_graph(dir: &Path, go_mod: &str, cache: Option<&Path>) -> Mod go_sum: None, }], graph: None, + files: None, }; ws.read_graph_from(cache).await; let graph = ws.graph.unwrap_or_default(); @@ -532,6 +540,12 @@ impl GoWorkspace { go_mod_edit::parse_replace_entries(&self.work) } + /// Where this workspace's paths live: lexical in memory, on disk + /// otherwise. + pub fn paths(&self) -> PathSpace<'_> { + PathSpace::from_files(self.files.as_ref()) + } + /// The directory holding go.work. pub fn work_dir(&self) -> &Path { self.work_file.parent().unwrap_or(Path::new("")) @@ -540,10 +554,7 @@ impl GoWorkspace { /// The member whose directory is `dir`, matched as go matches a module /// root against the `use` list. pub fn member_at(&self, dir: &Path) -> Option<&Member> { - self.member_at_in(PathSpace::Disk, dir) - } - - fn member_at_in(&self, paths: PathSpace<'_>, dir: &Path) -> Option<&Member> { + let paths = self.paths(); let key = paths.key(dir); self.members.iter().find(|m| paths.key(&m.dir) == key) } @@ -551,16 +562,18 @@ impl GoWorkspace { /// The members inside `root`, with their `root`-relative directory /// (`""` for `root` itself, forward slashes). pub fn members_within(&self, root: &Path) -> Vec<(String, &Member)> { - self.members_within_in(PathSpace::Disk, root) - } - - fn members_within_in(&self, paths: PathSpace<'_>, root: &Path) -> Vec<(String, &Member)> { + let paths = self.paths(); let base = paths.key(root); self.members .iter() .filter_map(|m| { let rel = paths.key(&m.dir).strip_prefix(&base).ok()?.to_path_buf(); - Some((rel.to_string_lossy().replace('\\', "/"), m)) + // In memory every relative key strips the empty root, so a + // member outside it (`../out`, `/abs`) is only told apart + // by its components. + rel.components() + .all(|c| matches!(c, std::path::Component::Normal(_))) + .then(|| (rel.to_string_lossy().replace('\\', "/"), m)) }) .collect() } @@ -585,10 +598,11 @@ impl GoWorkspace { /// [`Selection::Unknown`] without them. A module that is a `use` member /// is built from its directory, never at `version`. pub fn selection(&self, module: &str, version: &str) -> Selection { + let paths = self.paths(); if let Some(member) = self.main_module(module) { return Selection::Other(format!( "(the workspace module in {})", - display_path(self.work_dir(), &member.dir) + paths.display(self.work_dir(), &member.dir) )); } let mut complete = !self.members.is_empty() && self.graph.is_some(); @@ -756,11 +770,7 @@ impl GoWorkspace { /// `root` (`ci.work`) is not: go doesn't read `root/go.work` then, so /// a replace written there would do nothing (#1810). pub fn rooted_at(&self, root: &Path) -> bool { - self.rooted_at_in(PathSpace::Disk, root) - } - - /// [`GoWorkspace::rooted_at`] in `paths`. - pub fn rooted_at_in(&self, paths: PathSpace<'_>, root: &Path) -> bool { + let paths = self.paths(); self.work_file.file_name() == Some(std::ffi::OsStr::new("go.work")) && paths.key(self.work_dir()) == paths.key(root) } @@ -775,11 +785,12 @@ impl GoWorkspace { /// module's go.mod keeps the replace too /// ([`ReplaceHost::mirrors_root_go_mod`]). pub fn replace_host(&self, root: &Path, root_has_go_mod: bool, module: &str) -> ReplaceHost { + let paths = self.paths(); if !self.rooted_at(root) { return ReplaceHost::GoMod; } - let own = same_dir_key(root); - let root_is_member = self.members.iter().any(|m| same_dir_key(&m.dir) == own); + let own = paths.key(root); + let root_is_member = self.members.iter().any(|m| paths.key(&m.dir) == own); let ours_here = self .replaces() .iter() @@ -787,7 +798,7 @@ impl GoWorkspace { let listed_elsewhere = self .members .iter() - .any(|m| same_dir_key(&m.dir) != own && m.lists(module)); + .any(|m| paths.key(&m.dir) != own && m.lists(module)); let shared = !root_has_go_mod || (root_is_member && (ours_here || listed_elsewhere)); if shared { ReplaceHost::GoWork @@ -808,18 +819,7 @@ impl GoWorkspace { version: &str, ours: &ReplaceEntry, ) -> Option { - self.work_conflict_in(PathSpace::Disk, project_root, module, version, ours) - } - - /// [`GoWorkspace::work_conflict`] in `paths`. - pub fn work_conflict_in( - &self, - paths: PathSpace<'_>, - project_root: &Path, - module: &str, - version: &str, - ours: &ReplaceEntry, - ) -> Option { + let paths = self.paths(); let display = paths.display(project_root, &self.work_file); if let Some(member) = self.main_module(module) { return Some(WorkspaceConflict::MainModule { @@ -873,18 +873,7 @@ impl GoWorkspace { version: &str, ours: &ReplaceEntry, ) -> Option { - self.conflict_in(PathSpace::Disk, project_root, module, version, ours) - } - - /// [`GoWorkspace::conflict`] in `paths`. - pub fn conflict_in( - &self, - paths: PathSpace<'_>, - project_root: &Path, - module: &str, - version: &str, - ours: &ReplaceEntry, - ) -> Option { + let paths = self.paths(); let display = paths.display(project_root, &self.work_file); if let Some(member) = self.main_module(module) { return Some(WorkspaceConflict::MainModule { @@ -892,12 +881,12 @@ impl GoWorkspace { member: paths.display(project_root, &member.dir), }); } - let own = self.member_at_in(paths, project_root); + let own = self.member_at(project_root); // With no go.mod there is no file to write, even when the workspace // already replaces the module the same way (#1810). if own.is_none() && !paths.exists(&project_root.join("go.mod")) { let members: Vec = self - .members_within_in(paths, project_root) + .members_within(project_root) .into_iter() .map(|(dir, _)| dir) .filter(|dir| !dir.is_empty()) @@ -905,7 +894,7 @@ impl GoWorkspace { return Some(WorkspaceConflict::NoModule { work_file: display, members: members.join(", "), - own_work: !self.rooted_at_in(paths, project_root) + own_work: !self.rooted_at(project_root) && paths.is_file(&project_root.join("go.work")), }); } @@ -1160,6 +1149,90 @@ pub async fn module_roots(root: &Path) -> Vec { } } +/// An in-memory project's files, as [`read_memory`] reads them. Each +/// reader keeps its own access rules (what counts as unreadable, what a +/// failed read reports); what the workspace is made of is decided once. +pub(crate) trait MemoryFiles { + /// Whether root-relative `rel` exists, readable or not: a link, an + /// entry without content and a file go cannot parse all exist. + async fn exists(&mut self, rel: &str) -> bool; + /// `rel`'s text, when it can be read. + async fn read(&mut self, rel: &str) -> Option; + /// The sum file `rel`'s text, when this reader keeps sum files with + /// the workspace. + async fn read_sum(&mut self, rel: &str) -> Option; + /// The go.mod `rel` is one go cannot parse (`problem`). + fn unparseable(&mut self, rel: &str, problem: &str); +} + +/// The workspace of an in-memory project with the root `go.work` `work` +/// (normalized, parseable): its `use` members, those in the root with +/// their go.mod (`None` when unreadable or unparseable), those outside it +/// without (out of reach, so every selection stays unknown), and the files +/// that exist ([`GoWorkspace::files`]). There is no directory to resolve +/// from in memory, so every path stays root-relative and is compared +/// lexically. Shared by the in-memory hosted engine and discovery, so both +/// judge one workspace. Also returns the in-root members, as +/// `(root-relative dir, index into members)`. +pub(crate) async fn read_memory( + work: &str, + files: &mut impl MemoryFiles, +) -> (GoWorkspace, Vec<(String, usize)>) { + let at = |dir: &str, file: &str| match dir { + "" => file.to_string(), + dir => format!("{dir}/{file}"), + }; + let mut exist: BTreeSet = BTreeSet::from([PathBuf::from("go.work")]); + if files.exists("go.mod").await { + exist.insert("go.mod".into()); + } + let mut members = Vec::new(); + let mut in_root = Vec::new(); + for raw in go_mod_edit::parse_use_dirs(work) { + let Some(dir) = in_root_dir(&raw) else { + members.push(Member { + dir: raw.into(), + go_mod: None, + go_sum: None, + }); + continue; + }; + let rel = at(&dir, "go.mod"); + if !exist.contains(Path::new(&rel)) && files.exists(&rel).await { + exist.insert(rel.clone().into()); + } + let go_mod = match files.read(&rel).await { + Some(text) => { + let text = go_mod_edit::normalize_for_read(&text); + match go_mod_edit::block_structure_error(&text) { + Some(problem) => { + files.unparseable(&rel, &problem); + None + } + None => Some(text), + } + } + None => None, + }; + let go_sum = files.read_sum(&at(&dir, "go.sum")).await; + in_root.push((dir.clone(), members.len())); + members.push(Member { + dir: dir.into(), + go_mod, + go_sum, + }); + } + let ws = GoWorkspace { + work_file: "go.work".into(), + work: work.to_string(), + work_sum: files.read_sum("go.work.sum").await, + members, + graph: None, + files: Some(exist), + }; + (ws, in_root) +} + /// The root-relative directory of a `use` member spelled `dir` in a root /// go.work (`""` for the root itself, forward slashes); `None` for one /// outside the root. @@ -1176,8 +1249,8 @@ pub fn in_root_dir(dir: &str) -> Option { ) } -/// Where the paths of a [`GoWorkspace`] live, for the checks that compare -/// them ([`GoWorkspace::conflict_in`], [`GoWorkspace::work_conflict_in`]). +/// Where the paths of a [`GoWorkspace`] live ([`GoWorkspace::paths`]), for +/// the checks that compare them. #[derive(Debug, Clone, Copy)] pub enum PathSpace<'a> { /// On disk: matched as go matches them (absolute, @@ -1191,7 +1264,16 @@ pub enum PathSpace<'a> { Memory { files: &'a BTreeSet }, } -impl PathSpace<'_> { +impl<'a> PathSpace<'a> { + /// [`PathSpace::Memory`] over `files` when an in-memory project lists + /// them, else [`PathSpace::Disk`]. + pub fn from_files(files: Option<&'a BTreeSet>) -> Self { + match files { + Some(files) => PathSpace::Memory { files }, + None => PathSpace::Disk, + } + } + /// The key two spellings of one directory share. fn key(self, dir: &Path) -> PathBuf { match self { @@ -2175,4 +2257,124 @@ mod tests { // A member's own dir is a module root, not the workspace. assert_eq!(module_roots(&root.join("a")).await, vec![root.join("a")]); } + + /// An in-memory project for [`read_memory`]: `(path, Some(text))` is + /// readable, `(path, None)` exists without content (a link, an + /// unreadable entry). + struct MapFiles { + files: BTreeMap<&'static str, Option<&'static str>>, + unparseable: Vec, + } + + impl MapFiles { + fn new(files: &[(&'static str, Option<&'static str>)]) -> Self { + MapFiles { + files: files.iter().copied().collect(), + unparseable: Vec::new(), + } + } + } + + impl MemoryFiles for MapFiles { + async fn exists(&mut self, rel: &str) -> bool { + self.files.contains_key(rel) + } + async fn read(&mut self, rel: &str) -> Option { + self.files.get(rel).copied().flatten().map(str::to_string) + } + async fn read_sum(&mut self, rel: &str) -> Option { + self.read(rel).await + } + fn unparseable(&mut self, rel: &str, _problem: &str) { + self.unparseable.push(rel.to_string()); + } + } + + fn files(paths: &[&str]) -> Option> { + Some(paths.iter().map(PathBuf::from).collect()) + } + + /// The in-memory workspace's `files` are the files that exist: a root + /// go.mod go cannot parse, or one that cannot be read (a link), is + /// still the root's module, so a root module go.work doesn't `use` is + /// "not a member", never "no go.mod". A member go.mod go cannot parse + /// exists but carries no text. + #[tokio::test] + async fn read_memory_lists_the_files_that_exist() { + let work = "go 1.21\n\nuse (\n\t./b\n\t./c\n\t../out\n)\n"; + let b = "module example.com/b\n\ngo 1.21\n"; + let ours = replace(&format!("{DEP} v1.0.0 => ./fork")); + for root in [Some("module example.com/app\n\nrequire (\n"), None] { + let mut reader = MapFiles::new(&[ + ("go.mod", root), + ("go.work", Some(work)), + ("b/go.mod", Some(b)), + ("c/go.mod", Some("module example.com/c\n\nrequire (\n")), + ]); + let (ws, in_root) = read_memory(work, &mut reader).await; + assert_eq!( + ws.files, + files(&["go.mod", "go.work", "b/go.mod", "c/go.mod"]), + "{root:?}" + ); + assert_eq!(reader.unparseable, ["c/go.mod"]); + assert_eq!(ws.members[1].go_mod, None); + assert_eq!( + in_root, + [("b".to_string(), 0), ("c".to_string(), 1)], + "{root:?}" + ); + assert!( + matches!( + ws.conflict(Path::new(""), DEP, "v1.0.0", &ours), + Some(WorkspaceConflict::NotAMember { .. }) + ), + "{root:?}" + ); + } + // No go.mod at all: nowhere to go. + let mut reader = MapFiles::new(&[("go.work", Some(work))]); + let (ws, _) = read_memory(work, &mut reader).await; + assert_eq!(ws.files, files(&["go.work"])); + assert!(matches!( + ws.conflict(Path::new(""), DEP, "v1.0.0", &ours), + Some(WorkspaceConflict::NoModule { .. }) + )); + } + + /// In memory every member path is root-relative, so the empty root is a + /// prefix of all of them: a member outside the root (`../out`, an + /// absolute path) is still never "within" it. + #[test] + fn memory_members_within_excludes_members_outside_the_root() { + let member = |dir: &str| Member { + dir: dir.into(), + go_mod: Some(format!("module example.com/{dir}\n")), + go_sum: None, + }; + let ws = GoWorkspace { + work_file: "go.work".into(), + work: String::new(), + work_sum: None, + members: vec![member("a"), member("../out"), member("/abs"), member("")], + graph: None, + files: files(&["go.work", "a/go.mod"]), + }; + let within: Vec = ws + .members_within(Path::new("")) + .into_iter() + .map(|(dir, _)| dir) + .collect(); + assert_eq!(within, ["a", ""]); + // The NoModule hint names in-root members only. + let ws = GoWorkspace { + members: vec![member("a"), member("../out")], + ..ws + }; + let ours = replace(&format!("{DEP} v1.0.0 => ./fork")); + match ws.conflict(Path::new(""), DEP, "v1.0.0", &ours) { + Some(WorkspaceConflict::NoModule { members, .. }) => assert_eq!(members, "a"), + other => panic!("{other:?}"), + } + } } diff --git a/crates/socket-patch-core/src/vex/discover/golang.rs b/crates/socket-patch-core/src/vex/discover/golang.rs index 5943eaf5d..f3ea49b4c 100644 --- a/crates/socket-patch-core/src/vex/discover/golang.rs +++ b/crates/socket-patch-core/src/vex/discover/golang.rs @@ -128,7 +128,7 @@ //! conflict is asked. Otherwise a bypassed replace is never judged by //! version selection; one that is not bypassed and that selection makes //! inert is diagnosed, as in module mode. In memory every path is -//! compared lexically ([`PathSpace::Memory`]): nothing touches the disk. +//! compared lexically ([`go_workspace::PathSpace::Memory`]): nothing touches the disk. //! * **Hand-edit tolerance**: a UTF-8 BOM, CRLF line ends, tab-separated //! tokens, `replace(` without a space, `// comments`, and Go string //! literals (`"github.com/foo/bar"`, `` `…` ``) around escape- and @@ -154,9 +154,7 @@ use crate::vendor::go_mod_edit::{ ReplaceOwner, HOSTED_GO_MODULE_PREFIX, }; use crate::vendor::go_sum_edit::{go_semver_cmp, go_sum_lines, is_h1_dirhash}; -use crate::vendor::go_workspace::{ - self, in_root_dir, same_dir_key, GoWorkspace, Loaded, Member, PathSpace, -}; +use crate::vendor::go_workspace::{self, same_dir_key, GoWorkspace, Loaded}; use crate::vendor::lock_inventory::LockIntegrity; const GO_MOD: &str = "go.mod"; @@ -476,25 +474,19 @@ struct Workspace { root: PathBuf, /// Whether the model is the root's own `go.work`. rooted: bool, - /// In memory (and under [`withhold`]): the root-relative files that - /// exist, for a purely lexical [`PathSpace::Memory`]; `None` on disk. - files: Option>, } impl Workspace { - fn paths(&self) -> PathSpace<'_> { - match &self.files { - Some(files) => PathSpace::Memory { files }, - None => PathSpace::Disk, - } - } - /// The directory of the `use` member that IS `module`, as displayed: /// go builds it from there and ignores every replace of it, so such a /// replace is inert whatever else the workspace says. fn main_module_dir(&self, module: &str) -> Option { let member = self.model.main_module(module)?; - Some(self.paths().display(self.model.work_dir(), &member.dir)) + Some( + self.model + .paths() + .display(self.model.work_dir(), &member.dir), + ) } /// Why the Socket replace `entry` of `module@version` in the @@ -506,7 +498,7 @@ impl Workspace { /// A module that is itself a member is [`Workspace::main_module_dir`]'s. fn bypass(&self, file: &str, entry: &ReplaceEntry, version: &str) -> Option { let module = entry.module.as_str(); - let paths = self.paths(); + let paths = self.model.paths(); if file == GO_WORK { if !self.rooted { return Some(format!( @@ -517,7 +509,7 @@ impl Workspace { } return self .model - .work_conflict_in(paths, &self.root, module, version, entry) + .work_conflict(&self.root, module, version, entry) .map(|c| { format!( "the {GO_WORK} replace of {module} {version} {}", @@ -535,7 +527,7 @@ impl Workspace { self.root.join(dir) }; self.model - .conflict_in(paths, &project, module, version, entry) + .conflict(&project, module, version, entry) .map(|c| { format!( "the {file} replace of {module} {version} {}", @@ -545,53 +537,56 @@ impl Workspace { } } +/// The in-memory project's Go files as discovery reads them: through the +/// guarded reads (an unreadable file is diagnosed, its identities still +/// recognized), the root's go.mod as [`extract_with`] already read and +/// parsed it. The sum files are read once, by [`SumPins::read`]. +struct ViewGoFiles<'c, 'a, 'o, 'g> { + ctx: &'c DiscoverCtx<'a>, + out: &'o mut Discovery, + go_mod: Option<&'g str>, +} + +impl go_workspace::MemoryFiles for ViewGoFiles<'_, '_, '_, '_> { + async fn exists(&mut self, rel: &str) -> bool { + self.ctx.exists(rel).await + } + + async fn read(&mut self, rel: &str) -> Option { + if rel == GO_MOD { + return self.go_mod.map(str::to_string); + } + self.ctx.read_text(rel, self.out).await + } + + async fn read_sum(&mut self, _rel: &str) -> Option { + None + } + + fn unparseable(&mut self, rel: &str, problem: &str) { + diag_unparseable(self.out, rel, problem); + } +} + /// The root's own `go.work` (`go_work`) and its in-root members, read -/// through the view and matched lexically: the workspace in memory, and -/// what [`withhold`] reports. +/// through the view and matched lexically ([`go_workspace::read_memory`], +/// as the in-memory hosted engine reads them): the workspace in memory, +/// and what [`withhold`] reports. async fn read_workspace( ctx: &DiscoverCtx<'_>, out: &mut Discovery, go_mod: Option<&str>, go_work: &str, ) -> Workspace { - let mut members = Vec::new(); - let mut in_root = Vec::new(); + let mut files = ViewGoFiles { ctx, out, go_mod }; + let (model, in_root) = go_workspace::read_memory(go_work, &mut files).await; let mut sum_files = vec![GO_WORK_SUM.to_string()]; - let mut files: BTreeSet = [GO_WORK.into()].into_iter().collect(); - if go_mod.is_some() { - files.insert(GO_MOD.into()); - } - for raw in go_mod_edit::parse_use_dirs(go_work) { - let Some(dir) = in_root_dir(&raw) else { - members.push(Member { - dir: raw.into(), - go_mod: None, - go_sum: None, - }); - continue; - }; - let text = read_member(ctx, out, go_mod, &dir).await; - if text.is_some() { - files.insert(member_file(&dir, GO_MOD).into()); - } - let sum = member_file(&dir, GO_SUM); + for (dir, _) in &in_root { + let sum = member_file(dir, GO_SUM); if !sum_files.contains(&sum) { sum_files.push(sum); } - in_root.push((dir.clone(), members.len())); - members.push(Member { - dir: dir.into(), - go_mod: text, - go_sum: None, - }); - } - let model = GoWorkspace { - work_file: GO_WORK.into(), - work: go_work.to_string(), - work_sum: None, - members, - graph: None, - }; + } Workspace { model, in_root, @@ -599,7 +594,6 @@ async fn read_workspace( outside_sums: Vec::new(), root: PathBuf::new(), rooted: true, - files: Some(files), } } @@ -667,7 +661,6 @@ async fn read_loaded_workspace( outside_sums, root: root.to_path_buf(), rooted, - files: None, } } @@ -693,16 +686,22 @@ async fn read_member( fn parse_go_file(file: &str, text: &str, out: &mut Discovery) -> Option { let text = normalize_for_read(text); if let Some(problem) = block_structure_error(&text) { - out.diag( - DIAG_LOCKFILE_UNPARSEABLE, - file, - format!("{file}: {problem}; no patched modules read from it"), - ); + diag_unparseable(out, file, &problem); return None; } Some(text) } +/// [`DIAG_LOCKFILE_UNPARSEABLE`] for a go.mod-grammar `file` go cannot +/// parse (`problem`). +fn diag_unparseable(out: &mut Discovery, file: &str, problem: &str) { + out.diag( + DIAG_LOCKFILE_UNPARSEABLE, + file, + format!("{file}: {problem}; no patched modules read from it"), + ); +} + /// Emit the refs of one parsed file's Socket-owned `replace` directives. fn extract_file( out: &mut Discovery, @@ -2387,7 +2386,7 @@ mod tests { #[test] fn workspace_pin_of_a_dependency_raised_version_is_live() { use super::*; - use crate::vendor::go_workspace::DepGoMod; + use crate::vendor::go_workspace::{DepGoMod, Member}; let dep = |requires: &[(&str, &str)]| { Some(DepGoMod { pruned: true, @@ -2418,6 +2417,7 @@ mod tests { .into_iter() .collect(), ), + files: None, }; let graph = Graph::new(None, Some(&ws), None); let entry = |version: &str| ReplaceEntry { @@ -2847,6 +2847,63 @@ mod tests { assert!(!detail.contains(&*cwd.to_string_lossy()), "{detail}"); } + /// In memory the workspace's files are the files that exist, read as + /// the in-memory hosted engine reads them: a root go.mod go cannot + /// parse, or one that cannot be read (a link), is still the root's + /// module, so the root a go.work doesn't `use` is "not a member", the + /// verdict the engine's `memory_go_workspace_never_probes_the_cwd` + /// gives for the same projects. + #[tokio::test] + async fn in_memory_workspace_files_are_the_files_that_exist() { + use super::*; + use crate::vendor::go_workspace::WorkspaceConflict; + use crate::vendor::lock_inventory::{MemoryEntry, MemoryProject, ProjectView}; + let work = "go 1.21\n\nuse ./b\n"; + let ours = ReplaceEntry { + module: MODULE.into(), + version: Some(VERSION.into()), + path: None, + rhs_module: Some(gopatch(UUID_A)), + rhs_version: Some("v1.4.2-socketpatch.1".into()), + owner: Some(ReplaceOwner::Hosted), + }; + let unparseable = MemoryEntry::Text("module example.com/app\n\nrequire (\n".into()); + for root in [ + MemoryEntry::Text(BASE_GO_MOD.into()), + unparseable, + MemoryEntry::Symlink, + ] { + let mut project = MemoryProject::new(); + project.insert("go.mod", root.clone()); + project.insert_text("go.work", work); + project.insert_text("b/go.mod", "module example.com/b\n\ngo 1.21\n"); + let ctx = DiscoverCtx::over(ProjectView::Memory(&project), &[]); + let mut out = Discovery::default(); + let go_mod = match ctx.read_text(GO_MOD, &mut out).await { + Some(text) => super::parse_go_file(GO_MOD, &text, &mut out), + None => None, + }; + let ws = super::read_workspace(&ctx, &mut out, go_mod.as_deref(), work).await; + assert_eq!( + ws.model.files, + Some( + [GO_MOD, GO_WORK, "b/go.mod"] + .into_iter() + .map(PathBuf::from) + .collect() + ), + "{root:?}" + ); + assert!( + matches!( + ws.model.conflict(Path::new(""), MODULE, VERSION, &ours), + Some(WorkspaceConflict::NotAMember { .. }) + ), + "{root:?}" + ); + } + } + /// Review finding 7: a root go.work that go never reads (GOWORK names /// another) is never judged by go either: malformed, it is no /// `lockfile_unparseable`, and its Socket wiring stays a ref, unattested.