security_modal.rs

  1//! A UI interface for managing the [`TrustedWorktrees`] data.
  2
  3use std::{
  4    borrow::Cow,
  5    path::{Path, PathBuf},
  6    sync::Arc,
  7};
  8
  9use collections::{HashMap, HashSet};
 10use gpui::{DismissEvent, EventEmitter, FocusHandle, Focusable, WeakEntity};
 11
 12use project::{
 13    WorktreeId,
 14    trusted_worktrees::{PathTrust, RemoteHostLocation, TrustedWorktrees},
 15    worktree_store::WorktreeStore,
 16};
 17use smallvec::SmallVec;
 18use theme::ActiveTheme;
 19use ui::{
 20    AlertModal, Checkbox, FluentBuilder, KeyBinding, ListBulletItem, ToggleState, prelude::*,
 21};
 22
 23use crate::{DismissDecision, ModalView, ToggleWorktreeSecurity};
 24
 25pub struct SecurityModal {
 26    restricted_paths: HashMap<WorktreeId, RestrictedPath>,
 27    home_dir: Option<PathBuf>,
 28    trust_parents: bool,
 29    worktree_store: WeakEntity<WorktreeStore>,
 30    remote_host: Option<RemoteHostLocation>,
 31    focus_handle: FocusHandle,
 32    trusted: Option<bool>,
 33}
 34
 35#[derive(Debug, PartialEq, Eq)]
 36struct RestrictedPath {
 37    abs_path: Arc<Path>,
 38    is_file: bool,
 39    host: Option<RemoteHostLocation>,
 40}
 41
 42impl Focusable for SecurityModal {
 43    fn focus_handle(&self, _: &ui::App) -> FocusHandle {
 44        self.focus_handle.clone()
 45    }
 46}
 47
 48impl EventEmitter<DismissEvent> for SecurityModal {}
 49
 50impl ModalView for SecurityModal {
 51    fn fade_out_background(&self) -> bool {
 52        true
 53    }
 54
 55    fn on_before_dismiss(&mut self, _: &mut Window, _: &mut Context<Self>) -> DismissDecision {
 56        match self.trusted {
 57            Some(false) => telemetry::event!("Open in Restricted", source = "Worktree Trust Modal"),
 58            Some(true) => telemetry::event!("Trust and Continue", source = "Worktree Trust Modal"),
 59            None => telemetry::event!("Dismissed", source = "Worktree Trust Modal"),
 60        }
 61        DismissDecision::Dismiss(true)
 62    }
 63}
 64
 65impl Render for SecurityModal {
 66    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
 67        if self.restricted_paths.is_empty() {
 68            self.dismiss(cx);
 69            return v_flex().into_any_element();
 70        }
 71
 72        let header_label = if self.restricted_paths.len() == 1 {
 73            "Unrecognized Project"
 74        } else {
 75            "Unrecognized Projects"
 76        };
 77
 78        let trust_label = self.build_trust_label();
 79
 80        AlertModal::new("security-modal")
 81            .width(rems(40.))
 82            .key_context("SecurityModal")
 83            .track_focus(&self.focus_handle(cx))
 84            .on_action(cx.listener(|this, _: &menu::Confirm, _window, cx| {
 85                this.trust_and_dismiss(cx);
 86            }))
 87            .on_action(cx.listener(|security_modal, _: &ToggleWorktreeSecurity, _window, cx| {
 88                security_modal.trusted = Some(false);
 89                security_modal.dismiss(cx);
 90            }))
 91            .header(
 92                v_flex()
 93                    .p_3()
 94                    .gap_1()
 95                    .rounded_t_md()
 96                    .bg(cx.theme().colors().editor_background.opacity(0.5))
 97                    .border_b_1()
 98                    .border_color(cx.theme().colors().border_variant)
 99                    .child(
100                        h_flex()
101                            .gap_2()
102                            .child(Icon::new(IconName::Warning).color(Color::Warning))
103                            .child(Label::new(header_label)),
104                    )
105                    .children(self.restricted_paths.values().map(|restricted_path| {
106                        let abs_path = if restricted_path.is_file {
107                            restricted_path.abs_path.parent()
108                        } else {
109                            Some(restricted_path.abs_path.as_ref())
110                        };
111
112                        let label = match abs_path {
113                            Some(abs_path) => match &restricted_path.host {
114                                Some(remote_host) => match &remote_host.user_name {
115                                    Some(user_name) => format!(
116                                        "{} ({}@{})",
117                                        self.shorten_path(abs_path).display(),
118                                        user_name,
119                                        remote_host.host_identifier
120                                    ),
121                                    None => format!(
122                                        "{} ({})",
123                                        self.shorten_path(abs_path).display(),
124                                        remote_host.host_identifier
125                                    ),
126                                },
127                                None => self.shorten_path(abs_path).display().to_string(),
128                            },
129                            None => match &restricted_path.host {
130                                Some(remote_host) => match &remote_host.user_name {
131                                    Some(user_name) => format!(
132                                        "Workspace trust ({}@{})",
133                                        user_name, remote_host.host_identifier
134                                    ),
135                                    None => {
136                                        format!("Workspace trust ({})", remote_host.host_identifier)
137                                    }
138                                },
139                                None => "Workspace trust".to_string(),
140                            },
141                        };
142                        h_flex()
143                            .pl(IconSize::default().rems() + rems(0.5))
144                            .child(Label::new(label).color(Color::Muted))
145                    })),
146            )
147            .child(
148                v_flex()
149                    .gap_2()
150                    .child(
151                        v_flex()
152                            .child(
153                                Label::new(
154                                    "Untrusted projects are opened in Restricted Mode to protect your system.",
155                                )
156                                .color(Color::Muted),
157                            )
158                            .child(
159                                Label::new(
160                                    "Review .zed/settings.json for any extensions or commands configured by this project.",
161                                )
162                                .color(Color::Muted),
163                            ),
164                    )
165                    .child(
166                        v_flex()
167                            .child(Label::new("Restricted Mode prevents:").color(Color::Muted))
168                            .child(ListBulletItem::new("Project settings from being applied"))
169                            .child(ListBulletItem::new("Language servers from running"))
170                            .child(ListBulletItem::new("MCP Server integrations from installing")),
171                    )
172                    .map(|this| match trust_label {
173                        Some(trust_label) => this.child(
174                            Checkbox::new("trust-parents", ToggleState::from(self.trust_parents))
175                                .label(trust_label)
176                                .on_click(cx.listener(
177                                    |security_modal, state: &ToggleState, _, cx| {
178                                        security_modal.trust_parents = state.selected();
179                                        cx.notify();
180                                        cx.stop_propagation();
181                                    },
182                                )),
183                        ),
184                        None => this,
185                    }),
186            )
187            .footer(
188                h_flex()
189                    .px_3()
190                    .pb_3()
191                    .gap_1()
192                    .justify_end()
193                    .child(
194                        Button::new("rm", "Stay in Restricted Mode")
195                            .key_binding(
196                                KeyBinding::for_action(
197                                    &ToggleWorktreeSecurity,
198                                    cx,
199                                )
200                                .map(|kb| kb.size(rems_from_px(12.))),
201                            )
202                            .on_click(cx.listener(move |security_modal, _, _, cx| {
203                                security_modal.trusted = Some(false);
204                                security_modal.dismiss(cx);
205                                cx.stop_propagation();
206                            })),
207                    )
208                    .child(
209                        Button::new("tc", "Trust and Continue")
210                            .style(ButtonStyle::Filled)
211                            .layer(ui::ElevationIndex::ModalSurface)
212                            .key_binding(
213                                KeyBinding::for_action(&menu::Confirm, cx)
214                                    .map(|kb| kb.size(rems_from_px(12.))),
215                            )
216                            .on_click(cx.listener(move |security_modal, _, _, cx| {
217                                security_modal.trust_and_dismiss(cx);
218                                cx.stop_propagation();
219                            })),
220                    ),
221            )
222            .into_any_element()
223    }
224}
225
226impl SecurityModal {
227    pub fn new(
228        worktree_store: WeakEntity<WorktreeStore>,
229        remote_host: Option<impl Into<RemoteHostLocation>>,
230        cx: &mut Context<Self>,
231    ) -> Self {
232        let mut this = Self {
233            worktree_store,
234            remote_host: remote_host.map(|host| host.into()),
235            restricted_paths: HashMap::default(),
236            focus_handle: cx.focus_handle(),
237            trust_parents: false,
238            home_dir: std::env::home_dir(),
239            trusted: None,
240        };
241        this.refresh_restricted_paths(cx);
242
243        this
244    }
245
246    fn build_trust_label(&self) -> Option<Cow<'static, str>> {
247        let mut has_restricted_files = false;
248        let available_parents = self
249            .restricted_paths
250            .values()
251            .filter(|restricted_path| {
252                has_restricted_files |= restricted_path.is_file;
253                !restricted_path.is_file
254            })
255            .filter_map(|restricted_path| restricted_path.abs_path.parent())
256            .collect::<SmallVec<[_; 2]>>();
257        match available_parents.len() {
258            0 => {
259                if has_restricted_files {
260                    Some(Cow::Borrowed("Trust all single files"))
261                } else {
262                    None
263                }
264            }
265            1 => Some(Cow::Owned(format!(
266                "Trust all projects in the {:} folder",
267                self.shorten_path(available_parents[0]).display()
268            ))),
269            _ => Some(Cow::Borrowed("Trust all projects in the parent folders")),
270        }
271    }
272
273    fn shorten_path<'a>(&self, path: &'a Path) -> Cow<'a, Path> {
274        match &self.home_dir {
275            Some(home_dir) => path
276                .strip_prefix(home_dir)
277                .map(|stripped| Path::new("~").join(stripped))
278                .map(Cow::Owned)
279                .unwrap_or(Cow::Borrowed(path)),
280            None => Cow::Borrowed(path),
281        }
282    }
283
284    fn trust_and_dismiss(&mut self, cx: &mut Context<Self>) {
285        if let Some(trusted_worktrees) = TrustedWorktrees::try_get_global(cx) {
286            trusted_worktrees.update(cx, |trusted_worktrees, cx| {
287                let mut paths_to_trust = self
288                    .restricted_paths
289                    .keys()
290                    .copied()
291                    .map(PathTrust::Worktree)
292                    .collect::<HashSet<_>>();
293                if self.trust_parents {
294                    paths_to_trust.extend(self.restricted_paths.values().filter_map(
295                        |restricted_paths| {
296                            if restricted_paths.is_file {
297                                None
298                            } else {
299                                let parent_abs_path =
300                                    restricted_paths.abs_path.parent()?.to_owned();
301                                Some(PathTrust::AbsPath(parent_abs_path))
302                            }
303                        },
304                    ));
305                }
306                trusted_worktrees.trust(paths_to_trust, self.remote_host.clone(), cx);
307            });
308        }
309
310        self.trusted = Some(true);
311        self.dismiss(cx);
312    }
313
314    pub fn dismiss(&mut self, cx: &mut Context<Self>) {
315        cx.emit(DismissEvent);
316    }
317
318    pub fn refresh_restricted_paths(&mut self, cx: &mut Context<Self>) {
319        if let Some(trusted_worktrees) = TrustedWorktrees::try_get_global(cx) {
320            if let Some(worktree_store) = self.worktree_store.upgrade() {
321                let new_restricted_worktrees = trusted_worktrees
322                    .read(cx)
323                    .restricted_worktrees(worktree_store.read(cx), cx)
324                    .into_iter()
325                    .filter_map(|(worktree_id, abs_path)| {
326                        let worktree = worktree_store.read(cx).worktree_for_id(worktree_id, cx)?;
327                        Some((
328                            worktree_id,
329                            RestrictedPath {
330                                abs_path,
331                                is_file: worktree.read(cx).is_single_file(),
332                                host: self.remote_host.clone(),
333                            },
334                        ))
335                    })
336                    .collect::<HashMap<_, _>>();
337
338                if self.restricted_paths != new_restricted_worktrees {
339                    self.trust_parents = false;
340                    self.restricted_paths = new_restricted_worktrees;
341                    cx.notify();
342                }
343            }
344        } else if !self.restricted_paths.is_empty() {
345            self.restricted_paths.clear();
346            cx.notify();
347        }
348    }
349}