mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-09 17:20:38 -10:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f262e0cc0 | ||
|
|
4d39216b00 | ||
|
|
97ff542cdd |
No files matched your search
@@ -76,6 +76,4 @@ the [official plugins](plugins/official). The plugin API is still a preview.
|
||||
|
||||
[Apache 2.0](LICENSE). Bundled third-party assets are listed in [`THIRD_PARTY_LICENSES.md`](THIRD_PARTY_LICENSES.md).
|
||||
|
||||
|
||||
|
||||
<sub>Not affiliated with JetBrains. IntelliJ IDEA is a trademark of JetBrains s.r.o.</sub>
|
||||
@@ -922,13 +922,12 @@ fn plugin_file_edits(
|
||||
use ide_plugin::FileOperation;
|
||||
let confine = !a.operations.is_empty() || a.open.is_some();
|
||||
let confined = |path: &Path| ide_plugin::resolve_new_in_root(root, &path.to_string_lossy());
|
||||
let writable = |path: &Path| ide_plugin::resolve_writable_in_root(root, &path.to_string_lossy());
|
||||
let mut files = Vec::new();
|
||||
let mut created_with_text = Vec::new();
|
||||
for operation in &a.operations {
|
||||
match operation {
|
||||
FileOperation::Create { path, text, overwrite } => {
|
||||
let path = writable(path)?;
|
||||
let path = confined(path)?;
|
||||
files.push(FileEdit::Create { path: path.clone().into(), overwrite: *overwrite });
|
||||
if !text.is_empty() {
|
||||
let start = Range::point(Pos::new(0, 0));
|
||||
@@ -949,7 +948,7 @@ fn plugin_file_edits(
|
||||
}
|
||||
}
|
||||
for (path, edits) in &a.edits {
|
||||
let path = if confine { writable(path)? } else { path.clone() };
|
||||
let path = if confine { confined(path)? } else { path.clone() };
|
||||
if created_with_text.contains(&path) {
|
||||
return Err("put the text of a created file in its `create`, not in an edit".into());
|
||||
}
|
||||
@@ -2229,55 +2228,6 @@ mod tests {
|
||||
assert!(without_generated_helpers(Vec::new()).is_empty());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn plugins_file_actions_confine_content_writes_and_preserve_link_entries() {
|
||||
use ide_core::FileEdit;
|
||||
use ide_plugin::{CodeAction, FileOperation};
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join("proj");
|
||||
std::fs::create_dir(&root).unwrap();
|
||||
let root = dunce::canonicalize(root).unwrap();
|
||||
let outside = tmp.path().join("outside.txt");
|
||||
std::fs::write(&outside, "unchanged").unwrap();
|
||||
std::fs::write(root.join("target.txt"), "inside").unwrap();
|
||||
for (name, target) in [
|
||||
("outside", outside.clone()),
|
||||
("dangling", tmp.path().join("missing.txt")),
|
||||
("inside", root.join("target.txt")),
|
||||
] {
|
||||
std::os::unix::fs::symlink(target, root.join(name)).unwrap();
|
||||
}
|
||||
let action = |operations, edits| CodeAction {
|
||||
title: "Update files".into(),
|
||||
kind: None,
|
||||
preferred: false,
|
||||
edits,
|
||||
operations,
|
||||
open: Some(root.join("target.txt")),
|
||||
data: None,
|
||||
file_action: true,
|
||||
};
|
||||
for name in ["outside", "dangling"] {
|
||||
let create = FileOperation::Create { path: root.join(name), text: "text".into(), overwrite: true };
|
||||
assert!(plugin_file_edits(&root, &action(vec![create], Vec::new())).is_err());
|
||||
let edits = vec![(root.join(name), vec![TextEdit { range: Range::default(), new_text: "text".into() }])];
|
||||
assert!(plugin_file_edits(&root, &action(Vec::new(), edits)).is_err());
|
||||
}
|
||||
let create = FileOperation::Create { path: root.join("inside"), text: "text".into(), overwrite: true };
|
||||
let (files, _) = plugin_file_edits(&root, &action(vec![create], Vec::new())).unwrap();
|
||||
assert!(matches!(&files[0], FileEdit::Create { path, .. } if **path == root.join("target.txt")));
|
||||
let operations = vec![
|
||||
FileOperation::Rename { from: root.join("outside"), to: root.join("dangling") },
|
||||
FileOperation::Delete { path: root.join("outside") },
|
||||
];
|
||||
let (files, _) = plugin_file_edits(&root, &action(operations, Vec::new())).unwrap();
|
||||
assert!(matches!(&files[0], FileEdit::Rename { from, to }
|
||||
if **from == root.join("outside") && **to == root.join("dangling")));
|
||||
assert!(matches!(&files[1], FileEdit::Delete { path, .. } if **path == root.join("outside")));
|
||||
assert_eq!(std::fs::read_to_string(outside).unwrap(), "unchanged");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_plugin_action_maps_its_file_operations_before_its_edits_and_stays_in_the_project() {
|
||||
use ide_core::FileEdit;
|
||||
|
||||
@@ -163,10 +163,16 @@ impl SecretStore for FileSecretStore {
|
||||
None => map.remove(name),
|
||||
};
|
||||
let file = self.file(plugin);
|
||||
std::fs::create_dir_all(&self.dir).map_err(|e| e.to_string())?;
|
||||
ide_vfs::create_private_dir(file.parent().expect("has parent")).map_err(|e| e.to_string())?;
|
||||
std::fs::create_dir_all(file.parent().expect("has parent")).map_err(|e| e.to_string())?;
|
||||
let bytes = serde_json::to_vec(&map).map_err(|e| e.to_string())?;
|
||||
replace_entry(&file, &bytes).map_err(|e| e.to_string())
|
||||
let tmp = file.with_extension("tmp");
|
||||
std::fs::write(&tmp, bytes).map_err(|e| e.to_string())?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600));
|
||||
}
|
||||
std::fs::rename(&tmp, &file).map_err(|e| e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -918,15 +924,6 @@ fn storage_io(e: std::io::Error) -> HostError {
|
||||
HostError::Failed(format!("storage: {e}"))
|
||||
}
|
||||
|
||||
fn replace_entry(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
|
||||
use std::io::Write;
|
||||
let mut tmp =
|
||||
tempfile::Builder::new().prefix(".entry-").suffix(".tmp").tempfile_in(path.parent().expect("has parent"))?;
|
||||
tmp.write_all(bytes)?;
|
||||
tmp.persist(path).map_err(|e| e.error)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A key's file name: its hash, since keys may hold any character.
|
||||
fn entry_name(key: &str) -> String {
|
||||
blake3::hash(key.as_bytes()).to_hex().to_string()
|
||||
@@ -948,7 +945,8 @@ fn write_entry(dir: &Path, key: &str, value: &str) -> Result<(), HostError> {
|
||||
bytes.extend_from_slice(&len.to_le_bytes());
|
||||
bytes.extend_from_slice(key.as_bytes());
|
||||
bytes.extend_from_slice(value.as_bytes());
|
||||
replace_entry(&file, &bytes).map_err(storage_io)
|
||||
let tmp = file.with_extension("tmp");
|
||||
std::fs::write(&tmp, bytes).and_then(|()| std::fs::rename(&tmp, &file)).map_err(storage_io)
|
||||
}
|
||||
|
||||
/// The stored entries' files and the bytes of their keys and values.
|
||||
@@ -985,9 +983,6 @@ impl PluginApi {
|
||||
/// older version into it.
|
||||
fn storage(&self) -> Result<PathBuf, HostError> {
|
||||
let dir = self.shared.storage_dir(self.id());
|
||||
std::fs::create_dir_all(&self.shared.data_dir).map_err(storage_io)?;
|
||||
ide_vfs::create_private_dir(dir.parent().expect("has parent")).map_err(failed)?;
|
||||
ide_vfs::create_private_dir(&dir).map_err(failed)?;
|
||||
let legacy = self.shared.data_dir.join(self.id()).join("storage.json");
|
||||
match std::fs::read(&legacy) {
|
||||
Ok(bytes) => {
|
||||
@@ -2687,9 +2682,7 @@ impl HostApi for PluginApi {
|
||||
Err(e) if e.kind == ide_core::error::ErrorKind::NotFound => ("utf-8".to_string(), false),
|
||||
Err(e) => return Err(not_writable(real, e)),
|
||||
};
|
||||
let private = self.plugin_dir().is_ok_and(|dir| real.starts_with(dir));
|
||||
check_write_path(real)?;
|
||||
if private {
|
||||
if self.plugin_dir().is_ok_and(|dir| real.starts_with(dir)) {
|
||||
let bytes = ide_vfs::encode(text, &encoding, has_bom).map_err(|e| not_writable(real, e))?;
|
||||
ide_vfs::write_atomic_private(real, &bytes).map(drop).map_err(|e| not_writable(real, e))
|
||||
} else {
|
||||
@@ -2699,9 +2692,7 @@ impl HostApi for PluginApi {
|
||||
|
||||
fn write_bytes_file(&self, real: &Path, bytes: &[u8]) -> Result<(), HostError> {
|
||||
check_unsaved(self.shared.workspace(), real)?;
|
||||
let private = self.plugin_dir().is_ok_and(|dir| real.starts_with(dir));
|
||||
check_write_path(real)?;
|
||||
if private {
|
||||
if self.plugin_dir().is_ok_and(|dir| real.starts_with(dir)) {
|
||||
ide_vfs::write_atomic_private(real, bytes).map(drop).map_err(|e| not_writable(real, e))
|
||||
} else {
|
||||
ide_vfs::write_atomic(real, bytes).map(drop).map_err(|e| not_writable(real, e))
|
||||
@@ -2709,16 +2700,6 @@ impl HostApi for PluginApi {
|
||||
}
|
||||
}
|
||||
|
||||
/// Approved write paths are already resolved. Refuse substitutions since approval.
|
||||
fn check_write_path(path: &Path) -> Result<(), HostError> {
|
||||
let parent = path.parent().ok_or_else(|| HostError::Invalid("no directory to put it in".into()))?;
|
||||
let checked = ide_plugin::resolve_writable_in_root(parent, &to_ipc(path)).map_err(HostError::Invalid)?;
|
||||
if checked != path {
|
||||
return Err(HostError::Invalid(format!("{}: file path changed before writing", path.display())));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A file that is not there, or cannot be read, as the plugin sees it.
|
||||
fn not_a_file(path: &Path, e: std::io::Error) -> HostError {
|
||||
match e.kind() {
|
||||
@@ -3122,72 +3103,6 @@ mod tests {
|
||||
PluginApi { pkg: Package::new(manifest, dir.to_path_buf()), shared }
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn writes_recheck_approved_paths_after_symlink_substitution() {
|
||||
use std::os::unix::fs::symlink;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let api = api(tmp.path(), Arc::default());
|
||||
let root = tmp.path().join("project");
|
||||
std::fs::create_dir_all(root.join("nested")).unwrap();
|
||||
let path = ide_plugin::resolve_writable_in_root(&root, "nested/file.txt").unwrap();
|
||||
let outside = tmp.path().join("outside.txt");
|
||||
std::fs::write(&outside, "unchanged").unwrap();
|
||||
symlink(&outside, &path).unwrap();
|
||||
assert!(api.write_text_file(&path, "text").is_err());
|
||||
assert!(api.write_bytes_file(&path, b"bytes").is_err());
|
||||
assert_eq!(std::fs::read_to_string(&outside).unwrap(), "unchanged");
|
||||
|
||||
std::fs::remove_file(&path).unwrap();
|
||||
std::fs::remove_dir(root.join("nested")).unwrap();
|
||||
symlink(tmp.path(), root.join("nested")).unwrap();
|
||||
assert!(api.write_text_file(&path, "text").is_err());
|
||||
assert!(api.write_bytes_file(&path, b"bytes").is_err());
|
||||
assert!(!tmp.path().join("file.txt").exists());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn storage_and_secrets_do_not_write_through_temporary_symlinks() {
|
||||
use std::os::unix::fs::symlink;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let api = api(tmp.path(), Arc::default());
|
||||
let outside = tmp.path().join("outside.txt");
|
||||
std::fs::write(&outside, "unchanged").unwrap();
|
||||
let dir = api.storage().unwrap();
|
||||
let entry = dir.join(entry_name("key"));
|
||||
symlink(&outside, entry.with_extension("tmp")).unwrap();
|
||||
symlink(&outside, &entry).unwrap();
|
||||
api.storage_set("key", Some("value".into())).unwrap();
|
||||
assert_eq!(api.storage_get("key").unwrap().as_deref(), Some("value"));
|
||||
assert_eq!(std::fs::read_to_string(&outside).unwrap(), "unchanged");
|
||||
|
||||
let secrets = FileSecretStore { dir: tmp.path().join("secrets") };
|
||||
secrets.set("acme.lint", "key", Some("before")).unwrap();
|
||||
symlink(&outside, secrets.file("acme.lint").with_extension("tmp")).unwrap();
|
||||
secrets.set("acme.lint", "key", Some("after")).unwrap();
|
||||
assert_eq!(secrets.get("acme.lint", "key").as_deref(), Some("after"));
|
||||
assert_eq!(std::fs::read_to_string(&outside).unwrap(), "unchanged");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn storage_and_secrets_refuse_symlinked_private_directories() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let api = api(tmp.path(), Arc::default());
|
||||
let dir = api.storage().unwrap();
|
||||
std::fs::remove_dir(&dir).unwrap();
|
||||
let outside = tmp.path().join("outside");
|
||||
std::fs::create_dir(&outside).unwrap();
|
||||
std::os::unix::fs::symlink(&outside, &dir).unwrap();
|
||||
assert!(api.storage_set("key", Some("value".into())).is_err());
|
||||
let secrets = FileSecretStore { dir: tmp.path().join("secrets") };
|
||||
std::fs::create_dir(&secrets.dir).unwrap();
|
||||
std::os::unix::fs::symlink(&outside, secrets.dir.join("acme.lint")).unwrap();
|
||||
assert!(secrets.set("acme.lint", "key", Some("value")).is_err());
|
||||
assert_eq!(std::fs::read_dir(outside).unwrap().count(), 0);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn private_directory_matches_resolved_write_paths() {
|
||||
|
||||
@@ -542,30 +542,6 @@ fn project_write_stays_inside_the_project() {
|
||||
assert_eq!((info.status, info.stats.crashes, info.stats.errors), (PluginStatus::Active, 0, 0), "{info:?}");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn project_writes_confine_symlink_targets() {
|
||||
use std::os::unix::fs::symlink;
|
||||
let f = fixture(Limits::default());
|
||||
f.install(&misbehave_with(f._tmp.path(), "misbehave-symlink-writer", "project.write"));
|
||||
let outside = f._tmp.path().join("secret.txt");
|
||||
let missing = f._tmp.path().join("missing.txt");
|
||||
symlink(&outside, f.project.join("outside.txt")).unwrap();
|
||||
symlink(&missing, f.project.join("dangling.txt")).unwrap();
|
||||
for name in ["outside.txt", "dangling.txt"] {
|
||||
let error = f.run("misbehave", "projwrite", Some(name)).unwrap_err();
|
||||
assert!(error.contains("access outside the project root is not allowed"), "{error}");
|
||||
}
|
||||
assert_eq!(std::fs::read_to_string(outside).unwrap(), "top secret");
|
||||
assert!(!missing.exists());
|
||||
for (name, target) in [("inside.txt", "src/notes.txt"), ("new.txt", "new/deep.txt")] {
|
||||
symlink(target, f.project.join(name)).unwrap();
|
||||
f.run("misbehave", "projwrite", Some(name)).unwrap();
|
||||
assert_eq!(std::fs::read_to_string(f.project.join(target)).unwrap(), "written inside the project\n");
|
||||
assert!(std::fs::symlink_metadata(f.project.join(name)).unwrap().is_symlink());
|
||||
}
|
||||
}
|
||||
|
||||
/// `fs.write` replaces files the grant covers and nothing else, through the
|
||||
/// real component.
|
||||
#[test]
|
||||
|
||||
@@ -20,10 +20,7 @@ pub use runtime::{
|
||||
API_VERSION, CANCELLED, CallCancel, CallError, GuestError, HostApi, PluginEngine, Runtime, RuntimeSetup, check_api,
|
||||
component_api, host_api,
|
||||
};
|
||||
pub use sandbox::{
|
||||
MAX_READ_BYTES, inside_root, read_text, resolve_in_root, resolve_new_in_root, resolve_real,
|
||||
resolve_writable_in_root,
|
||||
};
|
||||
pub use sandbox::{MAX_READ_BYTES, inside_root, read_text, resolve_in_root, resolve_new_in_root, resolve_real};
|
||||
pub use types::*;
|
||||
pub use wasmtime::component::Component;
|
||||
|
||||
|
||||
@@ -117,45 +117,6 @@ pub fn resolve_new_in_root(root: &Path, path: &str) -> Result<PathBuf, String> {
|
||||
Ok(real)
|
||||
}
|
||||
|
||||
/// Resolves a content write's destination, including existing and dangling
|
||||
/// symlinks in the final component. Entry operations use [`resolve_new_in_root`].
|
||||
pub fn resolve_writable_in_root(root: &Path, path: &str) -> Result<PathBuf, String> {
|
||||
let (root, joined) = joined_path(root, path)?;
|
||||
let real = resolve_write_target(&joined, 40).map_err(|e| format!("{path}: {e}"))?;
|
||||
if !real.starts_with(&root) {
|
||||
return Err(format!("{path}: access outside the project root is not allowed"));
|
||||
}
|
||||
Ok(real)
|
||||
}
|
||||
|
||||
fn resolve_write_target(path: &Path, links_left: usize) -> Result<PathBuf, std::io::Error> {
|
||||
match dunce::canonicalize(path) {
|
||||
Ok(real) => return Ok(real),
|
||||
Err(error) => match std::fs::symlink_metadata(path) {
|
||||
Ok(meta) if meta.is_symlink() => {
|
||||
if links_left == 0 {
|
||||
return Err(std::io::Error::other("too many symbolic links"));
|
||||
}
|
||||
let target = std::fs::read_link(path)?;
|
||||
let target = if target.is_absolute() { target } else { path.parent().unwrap().join(target) };
|
||||
return resolve_write_target(&target, links_left - 1);
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||
_ => return Err(error),
|
||||
},
|
||||
}
|
||||
let parent = path.parent().ok_or_else(|| std::io::Error::other("no directory to put it in"))?;
|
||||
let mut real = resolve_write_target(parent, links_left)?;
|
||||
match path.components().next_back() {
|
||||
Some(std::path::Component::ParentDir) => {
|
||||
real.pop();
|
||||
}
|
||||
Some(std::path::Component::Normal(name)) => real.push(name),
|
||||
_ => {}
|
||||
}
|
||||
if real != path { resolve_write_target(&real, links_left) } else { Ok(real) }
|
||||
}
|
||||
|
||||
/// Reads a UTF-8 file that `resolve_in_root` already approved.
|
||||
pub fn read_text(real: &Path) -> Result<String, String> {
|
||||
let meta = std::fs::metadata(real).map_err(|e| e.to_string())?;
|
||||
@@ -233,9 +194,9 @@ pub fn resolve_granted(granted: &[Capability], path: &str) -> Result<PathBuf, Re
|
||||
/// The real path `path` would be written to, when an `fs.write:<path>` grant
|
||||
/// covers it.
|
||||
///
|
||||
/// Missing paths and dangling links are resolved to their target location;
|
||||
/// an existing file (or link) is followed first. The destination's directory
|
||||
/// must exist.
|
||||
/// A file that does not exist yet cannot be canonicalized, so its directory is
|
||||
/// canonicalized instead and the name appended; an existing file (or link) is
|
||||
/// followed first, so a link cannot be used to write outside the grant.
|
||||
pub fn resolve_writable(granted: &[Capability], path: &str) -> Result<PathBuf, Refusal> {
|
||||
resolve(granted, path, Wanted::Write)
|
||||
}
|
||||
@@ -280,14 +241,12 @@ fn resolve(granted: &[Capability], path: &str, wanted: Wanted) -> Result<PathBuf
|
||||
if covered { Ok(real) } else { Err(Refusal::Denied(cap.to_string())) }
|
||||
}
|
||||
|
||||
/// Where `asked` would be created, including a dangling link's target.
|
||||
/// Where `asked` would be created: its directory, resolved, plus its name.
|
||||
fn resolve_unsaved(asked: &Path) -> Result<PathBuf, String> {
|
||||
let real = resolve_write_target(asked, 40).map_err(|e| e.to_string())?;
|
||||
let parent = real.parent().ok_or_else(|| "no directory to put it in".to_string())?;
|
||||
if !parent.is_dir() {
|
||||
return Err("its directory does not exist".into());
|
||||
}
|
||||
Ok(real)
|
||||
let name = asked.file_name().ok_or_else(|| "not a file name".to_string())?;
|
||||
let parent = asked.parent().ok_or_else(|| "no directory to put it in".to_string())?;
|
||||
let dir = dunce::canonicalize(parent).map_err(|_| "its directory does not exist".to_string())?;
|
||||
Ok(dir.join(name))
|
||||
}
|
||||
|
||||
/// Simplifies Windows drive paths only when the plain form preserves their meaning.
|
||||
@@ -428,82 +387,6 @@ mod tests {
|
||||
assert!(resolve_new_in_root(&root, "out/x.txt").unwrap_err().contains("outside"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn writable_paths_follow_existing_and_dangling_symlinks() {
|
||||
use std::os::unix::fs::symlink;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join("proj");
|
||||
std::fs::create_dir(&root).unwrap();
|
||||
let root = dunce::canonicalize(root).unwrap();
|
||||
let outside = tmp.path().join("outside.txt");
|
||||
std::fs::write(&outside, "unchanged").unwrap();
|
||||
std::fs::write(root.join("inside.txt"), "before").unwrap();
|
||||
for (name, target) in [
|
||||
("outside", outside.clone()),
|
||||
("dangling-outside", tmp.path().join("missing.txt")),
|
||||
("inside", PathBuf::from("inside.txt")),
|
||||
("dangling-inside", PathBuf::from("new/deep.txt")),
|
||||
("chain", PathBuf::from("dangling-outside")),
|
||||
("cycle", PathBuf::from("cycle")),
|
||||
] {
|
||||
symlink(target, root.join(name)).unwrap();
|
||||
}
|
||||
for name in ["outside", "dangling-outside", "chain"] {
|
||||
assert!(resolve_writable_in_root(&root, name).unwrap_err().contains("outside the project root"));
|
||||
assert!(
|
||||
resolve_writable_in_root(&root, &format!("missing/../{name}"))
|
||||
.unwrap_err()
|
||||
.contains("outside the project root")
|
||||
);
|
||||
}
|
||||
assert!(resolve_writable_in_root(&root, "cycle").is_err());
|
||||
let inside = resolve_writable_in_root(&root, "inside").unwrap();
|
||||
assert_eq!(inside, root.join("inside.txt"));
|
||||
std::fs::write(inside, "after").unwrap();
|
||||
assert_eq!(std::fs::read_to_string(root.join("inside.txt")).unwrap(), "after");
|
||||
assert_eq!(resolve_writable_in_root(&root, "dangling-inside").unwrap(), root.join("new/deep.txt"));
|
||||
assert_eq!(std::fs::read_to_string(outside).unwrap(), "unchanged");
|
||||
assert!(!tmp.path().join("missing.txt").exists());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn writable_grants_confine_dangling_symlink_targets() {
|
||||
use std::os::unix::fs::symlink;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join("granted");
|
||||
std::fs::create_dir(&root).unwrap();
|
||||
let root = dunce::canonicalize(root).unwrap();
|
||||
let grant = Capability::FsWrite(strip_verbatim(&root));
|
||||
symlink("../missing.txt", root.join("outside")).unwrap();
|
||||
symlink("new.txt", root.join("inside")).unwrap();
|
||||
assert!(matches!(
|
||||
resolve_writable(std::slice::from_ref(&grant), root.join("outside").to_str().unwrap()),
|
||||
Err(Refusal::Denied(_))
|
||||
));
|
||||
assert_eq!(resolve_writable(&[grant], root.join("inside").to_str().unwrap()).unwrap(), root.join("new.txt"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn entry_operations_do_not_follow_outside_symlinks() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join("proj");
|
||||
std::fs::create_dir(&root).unwrap();
|
||||
let outside = tmp.path().join("outside.txt");
|
||||
std::fs::write(&outside, "unchanged").unwrap();
|
||||
std::os::unix::fs::symlink(&outside, root.join("link")).unwrap();
|
||||
let from = resolve_new_in_root(&root, "link").unwrap();
|
||||
let to = resolve_new_in_root(&root, "renamed").unwrap();
|
||||
std::fs::rename(&from, &to).unwrap();
|
||||
assert!(!from.try_exists().unwrap());
|
||||
assert!(std::fs::symlink_metadata(&to).unwrap().is_symlink());
|
||||
std::fs::remove_file(resolve_new_in_root(&root, "renamed").unwrap()).unwrap();
|
||||
assert!(std::fs::symlink_metadata(&to).is_err());
|
||||
assert_eq!(std::fs::read_to_string(outside).unwrap(), "unchanged");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn symlinks_cannot_escape() {
|
||||
|
||||
@@ -761,7 +761,7 @@ impl workspace::HostProject for StoreData {
|
||||
return Ok(Err(too_much_to_write(&path)));
|
||||
}
|
||||
let root = self.table.get(&this)?.root.clone();
|
||||
let real = match sandbox::resolve_writable_in_root(&root, &path) {
|
||||
let real = match sandbox::resolve_new_in_root(&root, &path) {
|
||||
Ok(real) => real,
|
||||
Err(e) => return Ok(Err(wit::Error::Invalid(e))),
|
||||
};
|
||||
|
||||
@@ -310,7 +310,9 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
max-width: 50%;
|
||||
/* May use the whole subject cell when space allows (#2447); shrinks with
|
||||
per-label ellipsis before crowding out the commit message. */
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
margin-left: auto;
|
||||
}
|
||||
@@ -328,7 +330,8 @@
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
min-width: 0;
|
||||
max-width: 180px;
|
||||
/* Sizes to content (#2447): truncates with ellipsis only when .refs runs
|
||||
out of room; the full name stays available via the title tooltip. */
|
||||
}
|
||||
/* Outlined tag glyph in the ref's colour. */
|
||||
.ref::before {
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { RefLabels } from "../GitLog";
|
||||
|
||||
const css = readFileSync("src/features/vcs/GitLog.module.css", "utf8");
|
||||
|
||||
const LONG_LOCAL = "feature-connection-storm-v2";
|
||||
const LONG_REMOTE = "gitlab/feature-connection-storm-v2";
|
||||
const REMOTES = new Set([LONG_REMOTE]);
|
||||
|
||||
describe("RefLabels long branch names (#2447)", () => {
|
||||
it("renders full names with title tooltips in regular mode", () => {
|
||||
render(<RefLabels refs={[LONG_LOCAL, LONG_REMOTE]} remoteNames={REMOTES} />);
|
||||
expect(screen.getByTitle(LONG_LOCAL)).toHaveTextContent(LONG_LOCAL);
|
||||
expect(screen.getByTitle(LONG_REMOTE)).toHaveTextContent(LONG_REMOTE);
|
||||
});
|
||||
|
||||
it("absorbs the remote into the local label in compact mode", () => {
|
||||
render(<RefLabels refs={[LONG_LOCAL, LONG_REMOTE]} compact remoteNames={REMOTES} />);
|
||||
expect(screen.getByTitle(`${LONG_LOCAL}, ${LONG_REMOTE}`)).toHaveTextContent(`gitlab & ${LONG_LOCAL}`);
|
||||
});
|
||||
|
||||
it("lets labels size to content: no fixed pixel cap, ellipsis and clipping kept", () => {
|
||||
const refBlocks = [...css.matchAll(/\.ref\s*\{([^}]*)\}/g)].map((m) => m[1]);
|
||||
expect(refBlocks.length).toBeGreaterThan(0);
|
||||
for (const block of refBlocks) expect(block).not.toMatch(/max-width:\s*\d+px/);
|
||||
expect(refBlocks[0]).toMatch(/text-overflow:\s*ellipsis/);
|
||||
const refsBlock = css.match(/\.refs\s*\{([^}]*)\}/)?.[1] ?? "";
|
||||
expect(refsBlock).toMatch(/overflow:\s*hidden/);
|
||||
});
|
||||
});
|
||||
@@ -17,6 +17,7 @@ import { installRepeatedDoubleClick } from "./platform/doubleClick";
|
||||
import { installErrorLogging, logError } from "./platform/errorLog";
|
||||
import { guessOs } from "./platform/platformInfo";
|
||||
import { installReorderedTapClick } from "./platform/reordered-tap-click";
|
||||
import { installWebKitClipboardGuard } from "./platform/webkitClipboard";
|
||||
import { installPageSwipeTracking } from "./platform/pageSwipe";
|
||||
import { installImeKeyGuard } from "./platform/ime";
|
||||
import { installKeyboardDispatcher } from "./platform/keymap";
|
||||
@@ -38,6 +39,7 @@ installRepeatedDoubleClick();
|
||||
// second press like the release before it, which the shim would take for a reordered tap and
|
||||
// click twice.
|
||||
if (guessOs() === "macos" && !/Chrome|Firefox/.test(navigator.userAgent)) installReorderedTapClick();
|
||||
if (guessOs() === "linux" && isTauri()) installWebKitClipboardGuard();
|
||||
installWindowDrag();
|
||||
installPointerFocus();
|
||||
installImeKeyGuard();
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { EditorSelection, EditorState, StateEffect } from "@codemirror/state";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { installWebKitClipboardGuard } from "./webkitClipboard";
|
||||
|
||||
const views: EditorView[] = [];
|
||||
let dispose: () => void;
|
||||
beforeEach(() => {
|
||||
dispose = installWebKitClipboardGuard();
|
||||
});
|
||||
afterEach(() => {
|
||||
dispose();
|
||||
views.splice(0).forEach((view) => view.destroy());
|
||||
document.body.replaceChildren();
|
||||
});
|
||||
|
||||
function editor(anchor = 5, head = anchor) {
|
||||
const view = new EditorView({
|
||||
doc: "first\nsecond\nthird",
|
||||
selection: EditorSelection.single(anchor, head),
|
||||
parent: document.body,
|
||||
});
|
||||
views.push(view);
|
||||
view.focus();
|
||||
return view;
|
||||
}
|
||||
|
||||
function key(target: HTMLElement, type = "keydown", init: KeyboardEventInit = { key: "x", ctrlKey: true }) {
|
||||
const event = new KeyboardEvent(type, { bubbles: true, cancelable: true, ...init });
|
||||
target.dispatchEvent(event);
|
||||
return event;
|
||||
}
|
||||
|
||||
function clipboard(target: HTMLElement, type = "cut") {
|
||||
const data = new Map<string, string>();
|
||||
const event = new Event(type, { bubbles: true, cancelable: true });
|
||||
Object.assign(event, { clipboardData: { clearData: () => data.clear(), setData: data.set.bind(data) } });
|
||||
target.dispatchEvent(event);
|
||||
return data.get("text/plain");
|
||||
}
|
||||
|
||||
describe("WebKitGTK clipboard events", () => {
|
||||
it.each([
|
||||
{ key: "x", ctrlKey: true },
|
||||
{ key: "Delete", shiftKey: true },
|
||||
])("cuts only the caret line for one $key keydown and two cut events (#962)", (shortcut) => {
|
||||
const view = editor();
|
||||
key(view.contentDOM, "keydown", shortcut);
|
||||
expect(clipboard(view.contentDOM)).toBe("first");
|
||||
expect(clipboard(view.contentDOM)).toBeUndefined();
|
||||
expect(view.state.doc.toString()).toBe("second\nthird");
|
||||
});
|
||||
|
||||
it.each([0, 2, 5, 6, 12, 18])("protects a caret at offset %i", (position) => {
|
||||
const view = editor(position);
|
||||
const line = view.state.doc.lineAt(position);
|
||||
const expected = view.state.doc.toString().slice(0, line.from) + view.state.doc.toString().slice(line.to + 1);
|
||||
key(view.contentDOM);
|
||||
expect(clipboard(view.contentDOM)).toBe(line.text);
|
||||
clipboard(view.contentDOM);
|
||||
expect(view.state.doc.toString()).toBe(expected);
|
||||
});
|
||||
|
||||
it("preserves a selected-text cut and the following line", () => {
|
||||
const view = editor(0, 5);
|
||||
key(view.contentDOM);
|
||||
expect(clipboard(view.contentDOM)).toBe("first");
|
||||
clipboard(view.contentDOM);
|
||||
expect(view.state.doc.toString()).toBe("\nsecond\nthird");
|
||||
});
|
||||
|
||||
it("cuts multiple caret lines once", () => {
|
||||
const view = editor();
|
||||
view.dispatch({ effects: StateEffect.appendConfig.of(EditorState.allowMultipleSelections.of(true)) });
|
||||
view.dispatch({ selection: EditorSelection.create([EditorSelection.cursor(5), EditorSelection.cursor(12)]) });
|
||||
key(view.contentDOM);
|
||||
expect(clipboard(view.contentDOM)).toBe("first\nsecond");
|
||||
clipboard(view.contentDOM);
|
||||
expect(view.state.doc.toString()).toBe("third");
|
||||
});
|
||||
|
||||
it("keeps a read-only document unchanged", () => {
|
||||
const view = editor();
|
||||
view.dispatch({ effects: StateEffect.appendConfig.of(EditorState.readOnly.of(true)) });
|
||||
key(view.contentDOM);
|
||||
expect(clipboard(view.contentDOM)).toBe("first");
|
||||
clipboard(view.contentDOM);
|
||||
expect(view.state.doc.toString()).toBe("first\nsecond\nthird");
|
||||
});
|
||||
|
||||
it("suppresses duplicate copies without changing the document", () => {
|
||||
const view = editor();
|
||||
key(view.contentDOM, "keydown", { key: "c", ctrlKey: true });
|
||||
expect(clipboard(view.contentDOM, "copy")).toBe("first");
|
||||
expect(clipboard(view.contentDOM, "copy")).toBeUndefined();
|
||||
expect(view.state.doc.toString()).toBe("first\nsecond\nthird");
|
||||
});
|
||||
|
||||
it.each(["cut", "copy"])("cancels the keypress fallback after the first %s", (type) => {
|
||||
const view = editor();
|
||||
const down = key(view.contentDOM);
|
||||
expect(down.defaultPrevented).toBe(false);
|
||||
clipboard(view.contentDOM, type);
|
||||
expect(down.defaultPrevented).toBe(true);
|
||||
});
|
||||
|
||||
it("allows successive key presses and auto-repeat", () => {
|
||||
const view = editor();
|
||||
for (const repeat of [false, true]) {
|
||||
key(view.contentDOM, "keydown", { key: "x", ctrlKey: true, repeat });
|
||||
expect(clipboard(view.contentDOM)).toBe(repeat ? "second" : "first");
|
||||
clipboard(view.contentDOM);
|
||||
}
|
||||
expect(view.state.doc.toString()).toBe("third");
|
||||
});
|
||||
|
||||
it.each(["keyup", "pointerdown", "blur"])("allows menu cuts after %s", (type) => {
|
||||
const view = editor();
|
||||
key(view.contentDOM);
|
||||
clipboard(view.contentDOM);
|
||||
if (type === "blur") window.dispatchEvent(new Event(type));
|
||||
else view.contentDOM.dispatchEvent(new Event(type, { bubbles: true }));
|
||||
expect(clipboard(view.contentDOM)).toBe("second");
|
||||
});
|
||||
|
||||
it("does not suppress repeated menu cuts without a keydown", () => {
|
||||
const view = editor();
|
||||
expect(clipboard(view.contentDOM)).toBe("first");
|
||||
expect(clipboard(view.contentDOM)).toBe("second");
|
||||
});
|
||||
|
||||
it("allows multiple explicit cuts from an action that handled its key", () => {
|
||||
const view = editor();
|
||||
view.contentDOM.addEventListener("keydown", (event) => event.preventDefault());
|
||||
key(view.contentDOM);
|
||||
expect(clipboard(view.contentDOM)).toBe("first");
|
||||
expect(clipboard(view.contentDOM)).toBe("second");
|
||||
});
|
||||
|
||||
it("does not carry a key press into another editor", () => {
|
||||
const first = editor();
|
||||
const second = editor();
|
||||
key(first.contentDOM);
|
||||
clipboard(first.contentDOM);
|
||||
expect(clipboard(second.contentDOM)).toBe("first");
|
||||
expect(clipboard(second.contentDOM)).toBe("second");
|
||||
});
|
||||
|
||||
it("does not intercept plain inputs or unrelated clipboard events", () => {
|
||||
const input = document.body.appendChild(document.createElement("input"));
|
||||
let cuts = 0;
|
||||
input.addEventListener("cut", () => cuts++);
|
||||
key(input);
|
||||
clipboard(input);
|
||||
clipboard(input);
|
||||
expect(cuts).toBe(2);
|
||||
const view = editor();
|
||||
let pastes = 0;
|
||||
view.contentDOM.addEventListener("paste", () => pastes++);
|
||||
key(view.contentDOM);
|
||||
for (let i = 0; i < 2; i++) view.contentDOM.dispatchEvent(new Event("paste", { bubbles: true }));
|
||||
expect(pastes).toBe(2);
|
||||
});
|
||||
|
||||
it("removes the guard on disposal", () => {
|
||||
const view = editor();
|
||||
key(view.contentDOM);
|
||||
clipboard(view.contentDOM);
|
||||
dispose();
|
||||
expect(clipboard(view.contentDOM)).toBe("second");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
/** WebKitGTK 2.54 can dispatch copy/cut twice for one keydown with a collapsed selection. */
|
||||
export function installWebKitClipboardGuard(): () => void {
|
||||
let pending: { editor: Element; keydown: KeyboardEvent; seen: Set<string> } | null = null;
|
||||
const reset = () => {
|
||||
pending = null;
|
||||
};
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
const editor = event.target instanceof Element ? event.target.closest(".cm-content") : null;
|
||||
pending = editor ? { editor, keydown: event, seen: new Set() } : null;
|
||||
};
|
||||
const onClipboard = (event: Event) => {
|
||||
if (!pending || !(event.target instanceof Element) || event.target.closest(".cm-content") !== pending.editor)
|
||||
return;
|
||||
// Actions that already handled the key may intentionally run several clipboard commands (macros).
|
||||
if (pending.seen.size === 0 && pending.keydown.defaultPrevented) return;
|
||||
if (pending.seen.has(event.type)) {
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
} else {
|
||||
pending.seen.add(event.type);
|
||||
// The native command can report failure after CodeMirror handles it. Stop its keypress fallback too.
|
||||
pending.keydown.preventDefault();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKeyDown, true);
|
||||
window.addEventListener("keyup", reset, true);
|
||||
window.addEventListener("pointerdown", reset, true);
|
||||
window.addEventListener("blur", reset, true);
|
||||
window.addEventListener("copy", onClipboard, true);
|
||||
window.addEventListener("cut", onClipboard, true);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", onKeyDown, true);
|
||||
window.removeEventListener("keyup", reset, true);
|
||||
window.removeEventListener("pointerdown", reset, true);
|
||||
window.removeEventListener("blur", reset, true);
|
||||
window.removeEventListener("copy", onClipboard, true);
|
||||
window.removeEventListener("cut", onClipboard, true);
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user