From 3c51b01955a682f77b022aef31c28c5a5f883c32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Wed, 14 Oct 2026 21:29:54 -0400 Subject: [PATCH] T16: a shell saves only when the running user may run it, as access answers, ACLs too, found at a full path or in one of PATH's full folders, never a relative one; a session runs that same full path, so the check and the start agree --- AGENTS.md | 2 +- src/settings/mod.rs | 103 ++++++++++++++++++++++++++++------------ src/term/shell.rs | 7 ++- src/term/shell/tests.rs | 8 ++++ 4 files changed, 87 insertions(+), 33 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b5069a1..da06c17 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -65,5 +65,5 @@ ELYTerminal. A terminal on GPUI and Ely components. `PRD.md` says what it does; - 2026-10-14: Files shows the shown session's folder in Ely's `Tree`, not `FileTree`, which takes every path up front and drops empty folders. A folder is read off the main thread when it first opens; the root and the open folders are read again whenever the session reports a folder, so at each prompt. Git's marks come from `git status --porcelain=v2 -z -- .`, run with `--no-optional-locks`, so it never holds `index.lock` against the user's own git, and under the user's `status.showUntrackedFiles`, so a home folder kept in git stays quick. An untracked folder marks what it holds. On macOS, whose file systems ignore Unicode forms, a name keeps its form on disk and meets Git's mark in composed form, as git's `core.precomposeunicode` writes it. A label built from a path shows each control character as its picture, `␊` for a newline: gpui shapes no newline, and a file may be named with one. A name not in UTF-8 keeps its bytes: its label shows it lossily, and its tree key adds the bytes after a NUL, which no path holds, so no two names share a key. A program without shell integration gets a line in the right column until it reports a folder. A control in a column header keeps its presses: Ely's header zooms the window on any double press. - 2026-10-14: git runs in English with `--literal-pathspecs` and without optional locks. Paths stay git's bytes until shown, so a name not in UTF-8 still names its own file; off Unix git writes UTF-8. Status reads porcelain v2. Paths to stage and unstage go in on stdin, NUL-ended (`--pathspec-from-file`), so any name and any count pass; an empty list is refused, since git would take it as the whole tree. Unstaging is `git reset`, which works before the first commit, and a staged rename unstages with its old path; a copy is a new file, its source no part of it. A diff is git's own, without external tools or text converters, a conflict against HEAD, our side, so a file we deleted reads as new; it is read into Ely's stretches; a binary file has none, and an untracked folder none either. Without an index git exits 1 for a difference and a failure alike; only a difference prints. What git warns of while it works goes to the log. The log needs a commit at HEAD: git refuses it before the first. - 2026-10-14: the Git panel shows HEAD's branch, or its short commit while detached; Ely's `ChangesList` of conflicted, staged, unstaged and untracked files; Ely's `CommitInput`; Ely's `CommitList` of the last 50 commits. A row's press opens its diff in Ely's fullscreen `Dialog` under the title bar, in Ely's `DiffViewer`, unified or split; a binary file, an untracked folder and a change of no line say so instead. Every control Ely draws works: stage, unstage, stage all, unstage all, amend, and discard, which asks first in Ely's `ConfirmDialog`: an edit goes back to the index (`git restore`); a new file, untracked or only intended to be added, is deleted (`git clean`, `git rm -f`). Git's clean passes over a repository of its own without a word, so a clean that removed nothing is an error. Actions run off the main thread, each after the one asked before it; a failure shows in Ely's `Alert` with git's words until dismissed or the next action, and after any action Files reads its marks again. A folder change drops the open diff, and one still loading, and blanks the view unless the new folder lies inside the shown work tree, so a cd within it does not blink; no action runs until the shown folder has read, so none reaches the repository left behind. A commit empties only the fields that still hold what it committed, and only once the shown folder has read and is in the same repository: a draft typed while it ran, or while a folder read, stays. A path shows on one line and once: as it is, or quoted as git quotes a name with control characters, quotes, backslashes or bytes not in UTF-8; a folder drops git's trailing slash, since no file shares its name. A section lists 500 files at most, and a line says how many are listed: Ely's list draws every row, and gpui copies every row's hitboxes and paint each frame, so 3000 rows halved the terminal's frames. The Git body is a cached view, so a terminal's output does not draw it again; it comes back from the Files tab with its lists scrolled to the top, since a view cached while hidden drops its widgets' state. Files stays uncached, so its tree keeps its open folders: Ely's `Tree` draws only the rows in view. -- 2026-10-14: Cmd-, opens the settings window, or brings the open one forward, and Cmd-W closes it; off macOS Ctrl-, and Ctrl-Shift-W, as Ghostty's. Categories sit in Ely's `SettingsLayout`, rows in its `SettingsRow`; a category joins the list with its rows. Each change goes through `SettingsStore::update`, which checks and saves, and every view reads the global, so it applies at once. A shell saves only when it names a program: empty for the login shell, a file at a full path, or a name on `PATH`; else the row says why and the saved one stays, since a bad shell stops the next start. The window takes focus inside when it opens, so its keys find its context. The app quits with its last terminal window: settings alone hold no terminal. +- 2026-10-14: Cmd-, opens the settings window, or brings the open one forward, and Cmd-W closes it; off macOS Ctrl-, and Ctrl-Shift-W, as Ghostty's. Categories sit in Ely's `SettingsLayout`, rows in its `SettingsRow`; a category joins the list with its rows. Each change goes through `SettingsStore::update`, which checks and saves, and every view reads the global, so it applies at once. A shell saves only when it names a program the running user may run, as the system's `access` answers, ACLs too: empty for the login shell, a file at a full path, or a name found in one of `PATH`'s full folders; a relative folder counts for none, since it would hang on each session's own folder. A session runs that same full path, so the check and the start agree; else the row says why and the saved one stays, since a bad shell stops the next start. The window takes focus inside when it opens, so its keys find its context. The app quits with its last terminal window: settings alone hold no terminal. - 2026-10-14: settings refuse unknown fields; a bad settings file stops the start and is never overwritten. diff --git a/src/settings/mod.rs b/src/settings/mod.rs index 131b17e..c27a24b 100644 --- a/src/settings/mod.rs +++ b/src/settings/mod.rs @@ -1,7 +1,12 @@ mod store; pub mod window; -use std::{env, ffi::OsStr, ops::RangeInclusive, path::Path}; +use std::{ + env, + ffi::OsStr, + ops::RangeInclusive, + path::{Path, PathBuf}, +}; use serde::{Deserialize, Serialize}; @@ -131,7 +136,7 @@ impl Settings { } anyhow::ensure!(!self.font_family.is_empty(), "font_family is empty"); anyhow::ensure!( - self.shell.is_empty() || runs(&self.shell, &env::var_os("PATH").unwrap_or_default()), + self.shell.is_empty() || program_path(&self.shell).is_some(), "shell is {:?}, which names no program", self.shell ); @@ -139,35 +144,44 @@ impl Settings { } } -/// Whether `shell` names a program a session starts: a file at a full path, or a name in one of `folders`, as `PATH` lists them. -fn runs(shell: &str, folders: &OsStr) -> bool { - let path = Path::new(shell); - if path.is_absolute() { - return program(path); - } - // A relative path would hang on each session's folder. - if path.components().count() != 1 { - return false; - } - env::split_paths(folders).any(|folder| { - let file = folder.join(path); - program(&file) || (cfg!(windows) && program(&file.with_extension("exe"))) - }) +/// The program `shell` names, as a session starts it: a file at a full path, or the first in `PATH`'s full folders. +pub fn program_path(shell: &str) -> Option { + find(shell, &env::var_os("PATH").unwrap_or_default()) } -/// Whether `path` is a file a session can run. +/// `shell`'s program, or the first one named so in `folders`, listed as `PATH` lists them. +fn find(shell: &str, folders: &OsStr) -> Option { + let path = Path::new(shell); + if path.is_absolute() { + return program(path).then(|| path.to_path_buf()); + } + // A relative path, or a relative folder, would hang on each session's own folder. + if path.components().count() != 1 { + return None; + } + let files = env::split_paths(folders) + .filter(|folder| folder.is_absolute()) + .flat_map(|folder| { + let file = folder.join(path); + let exe = cfg!(windows).then(|| file.with_extension("exe")); + [Some(file), exe] + }); + files.flatten().find(|file| program(file)) +} + +/// Whether `path` is a file the running user may run, as the system answers, ACLs too. +#[cfg(unix)] fn program(path: &Path) -> bool { - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let run = - |meta: std::fs::Metadata| meta.is_file() && meta.permissions().mode() & 0o111 != 0; - path.metadata().is_ok_and(run) - } - #[cfg(not(unix))] - { - path.is_file() - } + use std::{ffi::CString, os::unix::ffi::OsStrExt}; + let Ok(name) = CString::new(path.as_os_str().as_bytes()) else { + return false; + }; + path.is_file() && unsafe { libc::access(name.as_ptr(), libc::X_OK) } == 0 +} + +#[cfg(not(unix))] +fn program(path: &Path) -> bool { + path.is_file() } fn within( @@ -227,10 +241,12 @@ mod tests { assert!(shell("bin/sh").validate().is_err(), "a relative path"); #[cfg(unix)] { - assert!(runs("sh", OsStr::new("/bin"))); + let sh = Some(PathBuf::from("/bin/sh")); + assert_eq!(find("sh", OsStr::new("/bin")), sh); let root = OsStr::new("/"); - assert!( - !runs("bin/sh", root), + assert_eq!( + find("bin/sh", root), + None, "a relative path, though / holds bin/sh" ); assert!(shell("/bin/sh").validate().is_ok()); @@ -242,6 +258,31 @@ mod tests { } } + #[cfg(unix)] + #[test] + fn a_shell_runs_for_this_user_from_a_full_folder() { + use std::{fs, os::unix::fs::PermissionsExt}; + let folder = std::env::temp_dir().join(format!("elyterminal-shell-{}", std::process::id())); + fs::create_dir_all(&folder).expect("a test folder"); + let file = folder.join("review-shell"); + fs::write(&file, "#!/bin/sh\n").expect("a script"); + // Root may run what any execute bit allows. + let root = unsafe { libc::geteuid() } == 0; + for (mode, runs) in [(0o644, false), (0o641, root), (0o755, true)] { + fs::set_permissions(&file, fs::Permissions::from_mode(mode)).expect("a mode"); + assert_eq!(program(&file), runs, "mode {mode:o}"); + } + assert_eq!(find("review-shell", folder.as_os_str()), Some(file.clone())); + } + + #[test] + fn a_relative_folder_counts_for_none() { + // Tests run in the crate, whose scripts folder holds a program. + let scripts = OsStr::new("scripts"); + assert!(program(Path::new("scripts/check.sh"))); + assert_eq!(find("check.sh", scripts), None); + } + #[test] fn a_nan_is_out_of_range() { let settings = Settings { diff --git a/src/term/shell.rs b/src/term/shell.rs index c854daa..bb36632 100644 --- a/src/term/shell.rs +++ b/src/term/shell.rs @@ -77,7 +77,12 @@ pub fn launch(shell: &str, cwd: PathBuf, dir: &Path) -> anyhow::Result { let path = if shell.is_empty() { default_shell()? } else { - shell.to_string() + // The settings checked this path; the session runs no other. + let found = crate::settings::program_path(shell) + .with_context(|| format!("shell integration: {shell:?} names no program"))?; + (found.to_str()) + .with_context(|| format!("shell integration: {} is not UTF-8", found.display()))? + .to_string() }; let name = Path::new(&path) .file_stem() diff --git a/src/term/shell/tests.rs b/src/term/shell/tests.rs index b9c3a01..a1d0701 100644 --- a/src/term/shell/tests.rs +++ b/src/term/shell/tests.rs @@ -247,6 +247,14 @@ fn powershell_reports_its_folder_byte_for_byte_and_keeps_the_status() { run.finish(); } +#[test] +fn a_shell_named_bare_runs_from_its_full_path() { + let started = launch("sh", PathBuf::from("/"), Path::new("/nonexistent")).expect("a launch"); + let (program, _) = started.program.expect("a program"); + assert!(Path::new(&program).is_absolute(), "{program}"); + assert!(launch("no-such-shell", PathBuf::from("/"), Path::new("/")).is_err()); +} + #[test] fn an_unknown_shell_starts_without_integration() { let dir = PathBuf::from("/nonexistent");