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
2 changes: 2 additions & 0 deletions crates/fresh-editor/locales/cs.json
Original file line number Diff line number Diff line change
Expand Up @@ -955,6 +955,8 @@
"explorer.refreshed_default": "Obnoveno",
"explorer.refreshing": "Obnovuji %{name}...",
"explorer.rename_cancelled": "Přejmenování zrušeno",
"explorer.new_item_path_must_be_relative": "Cesta nové položky musí být relativní",
"explorer.new_item_path_outside_project": "Cesta nové položky musí zůstat v adresáři projektu",
"explorer.rename_invalid_dot": "Název nemůže být '.' ani '..'",
"explorer.rename_invalid_separator": "Název nemůže obsahovat oddělovač cesty",
"explorer.rename_prompt": "Přejmenovat na: ",
Expand Down
2 changes: 2 additions & 0 deletions crates/fresh-editor/locales/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -955,6 +955,8 @@
"explorer.refreshed_default": "Aktualisiert",
"explorer.refreshing": "Aktualisiere %{name}...",
"explorer.rename_cancelled": "Umbenennung abgebrochen",
"explorer.new_item_path_must_be_relative": "Der Pfad des neuen Elements muss relativ sein",
"explorer.new_item_path_outside_project": "Der Pfad des neuen Elements muss im Projektverzeichnis bleiben",
"explorer.rename_invalid_dot": "Name darf nicht '.' oder '..' sein",
"explorer.rename_invalid_separator": "Name darf keinen Pfadtrenner enthalten",
"explorer.rename_prompt": "Umbenennen zu: ",
Expand Down
2 changes: 2 additions & 0 deletions crates/fresh-editor/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -984,6 +984,8 @@
"explorer.pasted": "Pasted: %{name}",
"explorer.pasted_moved": "Moved: %{name}",
"explorer.rename_cancelled": "Rename cancelled",
"explorer.new_item_path_must_be_relative": "New item path must be relative",
"explorer.new_item_path_outside_project": "New item path must stay within the project directory",
"explorer.rename_invalid_dot": "Name cannot be '.' or '..'",
"explorer.rename_invalid_separator": "Name cannot contain '/'",
"explorer.rename_prompt": "Rename to: ",
Expand Down
2 changes: 2 additions & 0 deletions crates/fresh-editor/locales/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -955,6 +955,8 @@
"explorer.refreshed_default": "Actualizado",
"explorer.refreshing": "Actualizando %{name}...",
"explorer.rename_cancelled": "Renombrado cancelado",
"explorer.new_item_path_must_be_relative": "La ruta del nuevo elemento debe ser relativa",
"explorer.new_item_path_outside_project": "La ruta del nuevo elemento debe permanecer dentro del directorio del proyecto",
"explorer.rename_invalid_dot": "El nombre no puede ser '.' ni '..'",
"explorer.rename_invalid_separator": "El nombre no puede contener un separador de ruta",
"explorer.rename_prompt": "Renombrar a: ",
Expand Down
2 changes: 2 additions & 0 deletions crates/fresh-editor/locales/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -955,6 +955,8 @@
"explorer.refreshed_default": "Actualisé",
"explorer.refreshing": "Actualisation de %{name}...",
"explorer.rename_cancelled": "Renommage annulé",
"explorer.new_item_path_must_be_relative": "Le chemin du nouvel élément doit être relatif",
"explorer.new_item_path_outside_project": "Le chemin du nouvel élément doit rester dans le répertoire du projet",
"explorer.rename_invalid_dot": "Le nom ne peut pas être '.' ou '..'",
"explorer.rename_invalid_separator": "Le nom ne peut pas contenir de séparateur de chemin",
"explorer.rename_prompt": "Renommer en : ",
Expand Down
2 changes: 2 additions & 0 deletions crates/fresh-editor/locales/it.json
Original file line number Diff line number Diff line change
Expand Up @@ -955,6 +955,8 @@
"explorer.refreshed_default": "Aggiornato",
"explorer.refreshing": "Aggiornamento %{name}...",
"explorer.rename_cancelled": "Rinomina annullata",
"explorer.new_item_path_must_be_relative": "Il percorso del nuovo elemento deve essere relativo",
"explorer.new_item_path_outside_project": "Il percorso del nuovo elemento deve rimanere nella directory del progetto",
"explorer.rename_invalid_dot": "Il nome non può essere '.' o '..'",
"explorer.rename_invalid_separator": "Il nome non può contenere un separatore di percorso",
"explorer.rename_prompt": "Rinomina in: ",
Expand Down
2 changes: 2 additions & 0 deletions crates/fresh-editor/locales/ja.json
Original file line number Diff line number Diff line change
Expand Up @@ -955,6 +955,8 @@
"explorer.refreshed_default": "更新しました",
"explorer.refreshing": "%{name} を更新中...",
"explorer.rename_cancelled": "名前変更をキャンセル",
"explorer.new_item_path_must_be_relative": "新しい項目のパスは相対パスである必要があります",
"explorer.new_item_path_outside_project": "新しい項目のパスはプロジェクトディレクトリ内である必要があります",
"explorer.rename_invalid_dot": "名前に '.' や '..' は使えません",
"explorer.rename_invalid_separator": "名前にパス区切り文字は使えません",
"explorer.rename_prompt": "名前を変更: ",
Expand Down
2 changes: 2 additions & 0 deletions crates/fresh-editor/locales/ko.json
Original file line number Diff line number Diff line change
Expand Up @@ -955,6 +955,8 @@
"explorer.refreshed_default": "새로 고침됨",
"explorer.refreshing": "%{name} 새로 고침 중...",
"explorer.rename_cancelled": "이름 변경 취소됨",
"explorer.new_item_path_must_be_relative": "새 항목 경로는 상대 경로여야 합니다",
"explorer.new_item_path_outside_project": "새 항목 경로는 프로젝트 디렉터리 안에 있어야 합니다",
"explorer.rename_invalid_dot": "이름은 '.' 또는 '..'일 수 없습니다",
"explorer.rename_invalid_separator": "이름에 경로 구분 기호를 사용할 수 없습니다",
"explorer.rename_prompt": "이름 변경: ",
Expand Down
2 changes: 2 additions & 0 deletions crates/fresh-editor/locales/pt-BR.json
Original file line number Diff line number Diff line change
Expand Up @@ -955,6 +955,8 @@
"explorer.refreshed_default": "Atualizado",
"explorer.refreshing": "Atualizando %{name}...",
"explorer.rename_cancelled": "Renomeação cancelada",
"explorer.new_item_path_must_be_relative": "O caminho do novo item deve ser relativo",
"explorer.new_item_path_outside_project": "O caminho do novo item deve permanecer dentro do diretório do projeto",
"explorer.rename_invalid_dot": "O nome não pode ser '.' nem '..'",
"explorer.rename_invalid_separator": "O nome não pode conter um separador de caminho",
"explorer.rename_prompt": "Renomear para: ",
Expand Down
2 changes: 2 additions & 0 deletions crates/fresh-editor/locales/ru.json
Original file line number Diff line number Diff line change
Expand Up @@ -955,6 +955,8 @@
"explorer.refreshed_default": "Обновлено",
"explorer.refreshing": "Обновление %{name}...",
"explorer.rename_cancelled": "Переименование отменено",
"explorer.new_item_path_must_be_relative": "Путь нового элемента должен быть относительным",
"explorer.new_item_path_outside_project": "Путь нового элемента должен оставаться в каталоге проекта",
"explorer.rename_invalid_dot": "Имя не может быть '.' или '..'",
"explorer.rename_invalid_separator": "Имя не может содержать разделитель пути",
"explorer.rename_prompt": "Переименовать в: ",
Expand Down
2 changes: 2 additions & 0 deletions crates/fresh-editor/locales/th.json
Original file line number Diff line number Diff line change
Expand Up @@ -955,6 +955,8 @@
"explorer.refreshed_default": "รีเฟรชแล้ว",
"explorer.refreshing": "กำลังรีเฟรช %{name}...",
"explorer.rename_cancelled": "ยกเลิกการเปลี่ยนชื่อ",
"explorer.new_item_path_must_be_relative": "เส้นทางของรายการใหม่ต้องเป็นพาธสัมพัทธ์",
"explorer.new_item_path_outside_project": "เส้นทางของรายการใหม่ต้องอยู่ภายในไดเรกทอรีโครงการ",
"explorer.rename_invalid_dot": "ชื่อไม่สามารถเป็น '.' หรือ '..'",
"explorer.rename_invalid_separator": "ชื่อไม่สามารถมีตัวคั่นเส้นทาง",
"explorer.rename_prompt": "เปลี่ยนชื่อเป็น: ",
Expand Down
2 changes: 2 additions & 0 deletions crates/fresh-editor/locales/uk.json
Original file line number Diff line number Diff line change
Expand Up @@ -955,6 +955,8 @@
"explorer.refreshed_default": "Оновлено",
"explorer.refreshing": "Оновлення %{name}...",
"explorer.rename_cancelled": "Перейменування скасовано",
"explorer.new_item_path_must_be_relative": "Шлях нового елемента має бути відносним",
"explorer.new_item_path_outside_project": "Шлях нового елемента має залишатися в каталозі проєкту",
"explorer.rename_invalid_dot": "Ім'я не може бути '.' або '..'",
"explorer.rename_invalid_separator": "Ім'я не може містити роздільник шляху",
"explorer.rename_prompt": "Перейменувати на: ",
Expand Down
2 changes: 2 additions & 0 deletions crates/fresh-editor/locales/vi.json
Original file line number Diff line number Diff line change
Expand Up @@ -955,6 +955,8 @@
"explorer.refreshed_default": "Đã làm mới",
"explorer.refreshing": "Đang làm mới %{name}...",
"explorer.rename_cancelled": "Đã hủy đổi tên",
"explorer.new_item_path_must_be_relative": "Đường dẫn của mục mới phải là đường dẫn tương đối",
"explorer.new_item_path_outside_project": "Đường dẫn của mục mới phải nằm trong thư mục dự án",
"explorer.rename_invalid_dot": "Tên không thể là '.' hoặc '..'",
"explorer.rename_invalid_separator": "Tên không được chứa dấu phân cách đường dẫn",
"explorer.rename_prompt": "Đổi tên thành: ",
Expand Down
2 changes: 2 additions & 0 deletions crates/fresh-editor/locales/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -955,6 +955,8 @@
"explorer.refreshed_default": "已刷新",
"explorer.refreshing": "正在刷新 %{name}...",
"explorer.rename_cancelled": "重命名已取消",
"explorer.new_item_path_must_be_relative": "新项目路径必须是相对路径",
"explorer.new_item_path_outside_project": "新项目路径必须位于项目目录内",
"explorer.rename_invalid_dot": "名称不能为 '.' 或 '..'",
"explorer.rename_invalid_separator": "名称不能包含路径分隔符",
"explorer.rename_prompt": "重命名为:",
Expand Down
186 changes: 177 additions & 9 deletions crates/fresh-editor/src/app/file_explorer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -818,14 +818,24 @@ impl Editor {
return;
}

// Reject any platform path separator — `/` on all OSes plus `\` on
// Windows. `is_separator` is const-folded per platform so this keeps
// the same behavior on Linux (reject `/`) while also rejecting `\`
// when running on Windows.
if new_name.chars().any(std::path::is_separator) {
let requested_path = Path::new(&new_name);

// Existing items are renamed in place, so their names must remain a
// single path component. Newly-created items may include separators:
// their missing parent directories are created below before the
// temporary item is moved into place.
if !is_new_file && new_name.chars().any(std::path::is_separator) {
self.set_status_message(t!("explorer.rename_invalid_separator").to_string());
return;
}
// Joining an absolute/rooted path would discard the directory in
// which creation started. Prefix components cover Windows drive and
// UNC paths; RootDir covers `/foo` and `\foo`. Tilde and environment
// variable syntax remain ordinary, literal relative components.
if is_new_file && !is_relative_creation_path(requested_path) {
self.set_status_message(t!("explorer.new_item_path_must_be_relative").to_string());
return;
}
if new_name == "." || new_name == ".." {
self.set_status_message(t!("explorer.rename_invalid_dot").to_string());
return;
Expand All @@ -837,10 +847,35 @@ impl Editor {
.unwrap_or_else(|| original_path.clone());

if self.tokio_runtime.is_some() {
let result = self
.authority()
.filesystem
.rename(&original_path, &new_path);
let fs = std::sync::Arc::clone(&self.authority().filesystem);
if is_new_file {
match creation_path_is_within_project(fs.as_ref(), self.working_dir(), &new_path) {
Ok(true) => {}
Ok(false) => {
self.set_status_message(
t!("explorer.new_item_path_outside_project").to_string(),
);
return;
}
Err(e) => {
self.set_status_message(
t!("explorer.error_renaming", error = e.to_string()).to_string(),
);
return;
}
}
}
let result = if is_new_file {
// `create_dir_all` is also safe when the parent already
// exists. Keep both operations on the active filesystem so
// local, virtual, and remote workspaces behave alike.
new_path
.parent()
.map_or(Ok(()), |parent| fs.create_dir_all(parent))
.and_then(|()| fs.rename(&original_path, &new_path))
} else {
fs.rename(&original_path, &new_path)
};

match result {
Ok(_) => {
Expand Down Expand Up @@ -2109,3 +2144,136 @@ fn split_stem_ext(name: &str) -> (&str, &str) {
}
(name, "")
}

/// Whether a user-entered creation path stays relative to the selected
/// directory. `Path::is_absolute` is insufficient on Windows because a drive
/// prefix can replace the base path without being absolute (for example
/// `C:foo`), so reject both root and prefix components explicitly.
fn is_relative_creation_path(path: &Path) -> bool {
!matches!(
path.components().next(),
Some(std::path::Component::RootDir | std::path::Component::Prefix(_))
)
}

/// Check a not-yet-created target against the project root without creating
/// any of its missing parents first.
///
/// The lexical check catches `..` escaping through missing directories. The
/// canonical check resolves the deepest existing ancestor, catching paths
/// that leave the project through a symlink. Reattaching the missing tail is
/// safe because none of those components exist yet and therefore none can be
/// symlinks at the time of this check.
fn creation_path_is_within_project(
fs: &dyn crate::model::filesystem::FileSystem,
project_root: &Path,
target: &Path,
) -> std::io::Result<bool> {
let lexical_root = crate::app::normalize_path(project_root);
let lexical_target = crate::app::normalize_path(target);
if !lexical_target.starts_with(&lexical_root) {
return Ok(false);
}

let canonical_root = fs.canonicalize(project_root)?;
let canonical_target = canonicalize_deepest_existing(fs, target)?;
Ok(canonical_target.starts_with(canonical_root))
}

/// Canonicalize the deepest existing ancestor and append its missing path
/// components. Unlike `Path::canonicalize`, this also works for a target file
/// and parent directories that have not been created yet.
fn canonicalize_deepest_existing(
fs: &dyn crate::model::filesystem::FileSystem,
path: &Path,
) -> std::io::Result<PathBuf> {
let mut ancestor = path;
let mut missing_tail = Vec::new();

loop {
match fs.canonicalize(ancestor) {
Ok(mut canonical) => {
for component in missing_tail.iter().rev() {
canonical.push(component);
}
return Ok(crate::app::normalize_path(&canonical));
}
Err(error) => {
let Some(parent) = ancestor.parent() else {
return Err(error);
};
let Ok(component) = ancestor.strip_prefix(parent) else {
return Err(error);
};
missing_tail.push(component.to_path_buf());
ancestor = parent;
}
}
}
}

#[cfg(test)]
mod creation_path_tests {
use super::{creation_path_is_within_project, is_relative_creation_path};
use crate::model::filesystem::StdFileSystem;
use std::path::Path;

#[test]
fn nested_and_parent_relative_creation_paths_are_allowed() {
assert!(is_relative_creation_path(Path::new(
"src/components/app.rs"
)));
assert!(is_relative_creation_path(Path::new("../shared/app.rs")));
assert!(is_relative_creation_path(Path::new("./app.rs")));
assert!(is_relative_creation_path(Path::new("~/app.rs")));
assert!(is_relative_creation_path(Path::new("$HOME/app.rs")));
}

#[test]
fn rooted_creation_paths_are_rejected() {
assert!(!is_relative_creation_path(Path::new("/tmp/app.rs")));

#[cfg(windows)]
{
assert!(!is_relative_creation_path(Path::new(r"C:\tmp\app.rs")));
assert!(!is_relative_creation_path(Path::new(r"C:app.rs")));
assert!(!is_relative_creation_path(Path::new(r"\tmp\app.rs")));
}
}

#[test]
fn creation_path_cannot_escape_project_with_parent_components() {
let project = tempfile::tempdir().unwrap();
let fs = StdFileSystem;

assert!(creation_path_is_within_project(
&fs,
project.path(),
&project.path().join("src/../app.rs")
)
.unwrap());
assert!(!creation_path_is_within_project(
&fs,
project.path(),
&project.path().join("../escaped.rs")
)
.unwrap());
}

#[cfg(unix)]
#[test]
fn creation_path_cannot_escape_project_through_symlink() {
let container = tempfile::tempdir().unwrap();
let project = container.path().join("project");
let outside = container.path().join("outside");
std::fs::create_dir(&project).unwrap();
std::fs::create_dir(&outside).unwrap();
std::os::unix::fs::symlink(&outside, project.join("link")).unwrap();
let fs = StdFileSystem;

assert!(
!creation_path_is_within_project(&fs, &project, &project.join("link/escaped.rs"))
.unwrap()
);
}
}
Loading
Loading