fix: trusted interpreters only, plugin diffs off the disk, fresh check_files diagnostics, dotted Maven artifact ids (#3093)

- Python: All Interpreters ran the interpreter of every recent project, including ones last opened in Restricted Mode; only projects trusted in their own right are probed.
- Plugins: views.open-diff's file name became a path the diff viewer probed on disk; plugin diffs get a virtual plugin-diff: path and virtual paths aren't probed.
- MCP: check_files took a publication from before an already-applied outside edit as fresh; the LSP document records when its servers last got new text.
- Maven: version completion split dotted artifact ids into directories; only the group id is split.
This commit is contained in:
l0ng-ai
2026-11-13 15:29:24 +08:00
committed by GitHub
parent c942b87abb
commit d890c41352
10 changed files with 93 additions and 21 deletions
+24 -7
View File
@@ -393,11 +393,13 @@ fn scan_versions(dir: &Path, typed: &str, budget: &mut u32) -> Vec<PomCompletion
out
}
/// The repository directory of the `/`-separated `coordinates` (a group id, or
/// a group and an artifact id), each segment validated against the layout's
/// alphabet so POM text cannot walk outside `repo`.
/// The repository directory of `coordinates` (a group id, or a group and an artifact
/// id): the group's dots are directories, an artifact id is one directory whatever dots
/// it has (`org/eclipse/platform/org.eclipse.core.runtime`). Each segment is validated
/// against the layout's alphabet so POM text cannot walk outside `repo`.
fn repo_dir(repo: &Path, coordinates: &[&str]) -> Option<PathBuf> {
let segments: Vec<&str> = coordinates.iter().flat_map(|c| c.split('.')).collect();
let (group, artifact) = coordinates.split_first()?;
let segments: Vec<&str> = group.split('.').chain(artifact.iter().copied()).collect();
segments.iter().all(|s| segment(s)).then(|| segments.iter().fold(repo.to_path_buf(), |p, s| p.join(s)))
}
@@ -460,6 +462,7 @@ mod tests {
"org/example/lib/2.0-beta-1/lib-2.0-beta-1.jar",
"org/example/lib/2.0/lib-2.0.pom",
"net/nu/netty/4.1.100/netty-4.1.100.jar",
"org/eclipse/platform/org.eclipse.core.runtime/3.31.0/org.eclipse.core.runtime-3.31.0.jar",
// Failed downloads (a version typed while editing): tracking files only, not installed.
"org/example/lib/2/lib-2.jar.lastUpdated",
"org/example/lib/3.0/lib-3.0.pom.lastUpdated",
@@ -570,9 +573,10 @@ mod tests {
};
// Only directories holding artifacts are group ids: `net` and `org`
// alone never receive an install, their deeper levels do.
assert_eq!(options(""), ["net.nu", "org.apache.maven", "org.apache.maven.plugins", "org.example"]);
assert_eq!(options("org"), ["org.apache.maven", "org.apache.maven.plugins", "org.example"]);
assert_eq!(options("org."), ["org.apache.maven", "org.apache.maven.plugins", "org.example"]);
let org = ["org.apache.maven", "org.apache.maven.plugins", "org.eclipse.platform", "org.example"];
assert_eq!(options(""), [&["net.nu"][..], &org].concat());
assert_eq!(options("org"), org);
assert_eq!(options("org."), org);
assert_eq!(options("org.spr"), Vec::<String>::new());
assert_eq!(options("net."), ["net.nu"]);
}
@@ -623,6 +627,19 @@ mod tests {
};
assert_eq!(options(""), ["2.0", "2.0-beta-1", "1.10", "1.9.1", "1.0"]);
assert_eq!(options("1."), ["1.10", "1.9.1", "1.0"]);
// An artifact id with dots is one directory.
let dotted = local_options(
&repo,
&PomCompletionTarget {
from: 0,
to: 0,
typed: "".into(),
kind: PomCompletionKind::Version,
group: Some("org.eclipse.platform".into()),
artifact: Some("org.eclipse.core.runtime".into()),
},
);
assert_eq!(dotted.into_iter().map(|o| o.label).collect::<Vec<_>>(), ["3.31.0"]);
}
#[test]
+13 -6
View File
@@ -61,13 +61,16 @@ pub async fn sdk_python_project_interpreters(host: &Host, window: &str) -> IdeRe
host.require_trust(window)?;
let current = host.project(window).ok().map(|p| p.root.clone());
let config = host.config_dir.clone();
// Probing runs the project's interpreter (and its settings may name any program): only
// projects trusted in their own right, not ones last opened in Restricted Mode.
let roots: Vec<PathBuf> = ide_workspace::RecentProjects::load(&config)
.list()
.into_iter()
.map(|p| p.path.into_path_buf())
.filter(|root| current.as_deref() != Some(root.as_path()))
.filter(|root| host.trust.window_trusted(root).unwrap_or(false))
.collect();
tokio::task::spawn_blocking(move || {
let roots: Vec<PathBuf> = ide_workspace::RecentProjects::load(&config)
.list()
.into_iter()
.map(|p| p.path.into_path_buf())
.filter(|root| current.as_deref() != Some(root.as_path()))
.collect();
// Each project resolves its configured interpreter, else its own virtualenv. The
// probes run `python`, so keep them off the async runtime and bound how many run at
// once: the recent list can hold ~30 projects, and each probe starts a process.
@@ -521,6 +524,10 @@ mod tests {
host.trust.decide(&root, true, false).unwrap();
host.open_project("main", &root).unwrap();
// A project last opened in Restricted Mode: its interpreter is never run.
host.trust.decide(&other, false, false).unwrap();
assert!(sdk_python_project_interpreters(&host, "main").await.unwrap().is_empty());
host.trust.decide(&other, true, false).unwrap();
let list = sdk_python_project_interpreters(&host, "main").await.unwrap();
assert_eq!(list.len(), 1, "{list:?}");
assert_eq!(&*list[0].project, other.as_path());
+3
View File
@@ -239,6 +239,9 @@ impl LspManager {
cancel: &CancellationToken,
) -> IdeResult<DocumentCheck> {
let snap = self.snapshot(doc)?;
// The text may have changed before this call synced it (the file watcher reloaded it):
// only diagnostics published after the servers got the current text are fresh.
let changed = Some(changed.map_or(snap.changed, |c| c.max(snap.changed)));
let deadline = tokio::time::Instant::now() + timeout;
let left = || deadline.saturating_duration_since(tokio::time::Instant::now());
let ready = tokio::select! {
+7
View File
@@ -120,6 +120,8 @@ struct DocState {
compose_detected: bool,
/// The notebook a code cell belongs to: synced through `notebookDocument/*`, not as a file.
notebook: Option<PathBuf>,
/// When the servers last got new text (open or change): diagnostics published before are stale.
changed: std::time::Instant,
}
impl DocState {
@@ -158,6 +160,8 @@ struct DocSnap {
language: LanguageId,
text: Arc<DocText>,
servers: Vec<Arc<Server>>,
/// [`DocState::changed`].
changed: std::time::Instant,
}
impl DocSnap {
@@ -1170,6 +1174,7 @@ impl LspManager {
servers: servers.iter().map(|s| Attached { server: s.clone(), opened_gen: None }).collect(),
compose_detected,
notebook: None,
changed: std::time::Instant::now(),
};
let compose_added = {
let mut docs = self.docs.lock();
@@ -1217,6 +1222,7 @@ impl LspManager {
let mut docs = self.docs.lock();
let Some(d) = docs.get_mut(&doc) else { return };
d.version = version as i32;
d.changed = std::time::Instant::now();
// Servers that have this document open on their current instance.
let targets: Vec<(Arc<Server>, Running, SyncKind, Encoding)> = d
@@ -1392,6 +1398,7 @@ impl LspManager {
language: d.language.clone(),
text: d.text.clone(),
servers,
changed: d.changed,
})
}
+3
View File
@@ -213,6 +213,7 @@ impl LspManager {
{
if d.text.as_str() != cell.source {
Arc::make_mut(&mut d.text).set(&cell.source);
d.changed = std::time::Instant::now();
}
d.version += 1;
state.cells.push((cell.id.clone(), doc));
@@ -234,6 +235,7 @@ impl LspManager {
servers: state.servers.iter().map(|s| Attached { server: s.clone(), opened_gen: None }).collect(),
compose_detected: false,
notebook: Some(path.to_owned()),
changed: std::time::Instant::now(),
},
);
state.cells.push((cell.id.clone(), doc));
@@ -259,6 +261,7 @@ impl LspManager {
}
Arc::make_mut(&mut d.text).set(&cell.source);
d.version += 1;
d.changed = std::time::Instant::now();
content.push(json!({
"document": { "uri": d.uri, "version": d.version },
"changes": [{ "text": cell.source }],
+6
View File
@@ -2204,6 +2204,12 @@ async fn checking_a_document_saves_it_and_waits_for_fresh_diagnostics() {
let stale = h.mgr.check_document(doc, changed, Duration::from_millis(800), &cancel).await.unwrap();
assert!(stale.servers[0].checked && stale.servers[0].incomplete, "{:?}", stale.servers);
assert_eq!(stale.diagnostics.len(), 1);
// The text changed before the call (the file watcher reloaded it), so the caller saw no
// change of its own: the old publication is still not fresh.
h.mgr.did_change(doc, 2, &[change(rng(0, 0, 0, 3), "good")], &|| "good\n".to_owned());
let reloaded = h.mgr.check_document(doc, None, Duration::from_millis(800), &cancel).await.unwrap();
assert!(reloaded.servers[0].incomplete, "{:?}", reloaded.servers);
}
#[tokio::test(flavor = "multi_thread")]
@@ -61,8 +61,9 @@ pub fn artifacts_url(group: &str, prefix: &str) -> Option<String> {
/// The published version metadata of one artifact on Central.
pub fn metadata_url(group: &str, artifact: &str) -> Option<String> {
(part(group, 200) && part(artifact, 100)).then(|| {
let path = group.split('.').chain(artifact.split('.')).collect::<Vec<_>>().join("/");
// The group's dots are directories; an artifact id is one directory, dots and all.
(part(group, 200) && part(artifact, 100) && artifact != "." && artifact != "..").then(|| {
let path = group.split('.').chain([artifact]).collect::<Vec<_>>().join("/");
format!("https://repo1.maven.org/maven2/{path}/maven-metadata.xml")
})
}
@@ -146,6 +147,11 @@ mod tests {
metadata_url("org.apache.maven", "maven-core").as_deref(),
Some("https://repo1.maven.org/maven2/org/apache/maven/maven-core/maven-metadata.xml")
);
assert_eq!(
metadata_url("org.eclipse.platform", "org.eclipse.core.runtime").as_deref(),
Some("https://repo1.maven.org/maven2/org/eclipse/platform/org.eclipse.core.runtime/maven-metadata.xml")
);
assert_eq!(metadata_url("org.example", ".."), None);
}
#[test]
@@ -218,7 +218,7 @@ describe("plugin-backed content editor tabs", () => {
});
await waitFor(() => expect(diffs).toHaveLength(1));
expect(diffs[0]).toEqual({
path: "service.toml",
path: "plugin-diff:/service.toml",
title: "Diff: service.toml",
left: { type: "text", text: 'name = "demo"\nreplicas = 2\n', label: "Live" },
right: { type: "text", text: 'name = "demo"\nreplicas = 5\n', label: "Edited" },
@@ -239,18 +239,26 @@ describe("plugin-backed content editor tabs", () => {
openPluginDiffRequest({ left: "", right: "b\n", title: "MR !12" }, 10);
expect(diffs).toEqual([
{
path: "src/review.ts",
path: "plugin-diff:/review.ts",
title: "src/review.ts",
left: { type: "text", text: "a\n", label: "Target" },
right: { type: "text", text: "b\n", label: "After" },
},
{
path: "MR !12",
path: "plugin-diff:/MR !12",
title: "MR !12",
left: { type: "text", text: "", label: "Before" },
right: { type: "text", text: "b\n", label: "After" },
},
]);
// A file name only names the syntax: never a path on disk (a UNC share, say).
openPluginDiffRequest({ left: "a", right: "b", fileName: "\\\\attacker\\share\\x.rs" }, 10);
openPluginDiffRequest({ left: "a", right: "b", title: "C:\\Users\\x\\y.ts" }, 10);
expect(diffs.slice(2).map((d) => (d as { path: string }).path)).toEqual([
"plugin-diff:/x.rs",
"plugin-diff:/y.ts",
]);
diffs.splice(2);
expect(() => openPluginDiffRequest({ left: "a", right: 1 }, 10)).toThrow();
expect(() => openPluginDiffRequest({ left: "a", right: "b", fileName: "x\ny" }, 10)).toThrow();
expect(() => openPluginDiffRequest({ left: "a", right: "b" }, 4 << 20)).toThrow();
+15 -2
View File
@@ -176,9 +176,22 @@ function validDiff(v: unknown): ContentTabDiff | undefined {
}
/** Opens a plugin's two texts in the diff viewer; `path` picks the syntax highlighting. */
export function openPluginDiff(diff: ContentTabDiff, path: string, title: string) {
/**
* A plugin diff's path: the file name only picks the syntax highlighting. It is virtual, so the
* diff viewer never probes or opens a file of that name (a plugin may lack file system access).
*/
export function pluginDiffPath(name: string): string {
const base =
name
.split(/[\\/]/)
.pop()
?.replace(/[\u0000-\u001f:]/g, "") || "diff";
return `plugin-diff:/${base}`;
}
export function openPluginDiff(diff: ContentTabDiff, name: string, title: string) {
void executeAction("vcs.openDiff", {
path,
path: pluginDiffPath(name),
title: diff.title ?? title,
left: { type: "text", text: diff.left, label: diff.leftLabel ?? tx("Before") },
right: { type: "text", text: diff.right, label: diff.rightLabel ?? tx("After") },
+3 -1
View File
@@ -37,6 +37,7 @@ import { call, commands } from "@/platform/ipc";
import { bindingsFor, normalizeBinding, shortcutLabel, strokeOf } from "@/platform/keymap";
import { tx } from "@/platform/i18n";
import { useProject } from "@/platform/project";
import { isVirtualUri } from "@/platform/virtualDocs";
import { IconButton, Select, Spinner, T, showContextMenu } from "@/ui";
import { registerSoftWrapSurface } from "@/workbench/softWrap";
import { editorService, type EditorInput } from "@/workbench/contributions";
@@ -819,7 +820,8 @@ export function DiffEditor({
const fileSource = sideFile(left, path) ?? sideFile(right, path);
const [onDisk, setOnDisk] = useState<string | null>(null);
useEffect(() => {
if (fileSource) return;
// A virtual path (a plugin's text diff) names no file on disk.
if (fileSource || isVirtualUri(path)) return;
let alive = true;
void call(commands.fsProbe(path)).then(
() => alive && setOnDisk(path),