disallow opening private files (#7165)

Conrad Irwin , Piotr , and Mikayla created

- Disallow sharing gitignored files through collab
- Show errors when failing to open files
- Show a warning to followers when view is unshared

/cc @mikaylamaki, let's update this to use your `private_files` config
before merge.


Release Notes:

- Added the ability to prevent sharing private files over collab.

---------

Co-authored-by: Piotr <piotr@zed.dev>
Co-authored-by: Mikayla <mikayla@zed.dev>

Change summary

crates/collab/src/main.rs                 |  3 +
crates/editor/src/items.rs                |  8 +++
crates/language/src/buffer.rs             |  7 ++
crates/project/src/project.rs             | 54 ++++++++++++++++++------
crates/project_panel/Cargo.toml           |  1 
crates/project_panel/src/project_panel.rs | 13 +++++
crates/rpc/proto/zed.proto                |  1 
crates/rpc/src/error.rs                   | 14 ++++++
crates/workspace/src/pane_group.rs        | 19 +++++++
9 files changed, 101 insertions(+), 19 deletions(-)

Detailed changes

crates/collab/src/main.rs 🔗

@@ -28,6 +28,9 @@ async fn main() -> Result<()> {
         Some("version") => {
             println!("collab v{VERSION}");
         }
+        Some("migrate") => {
+            run_migrations().await?;
+        }
         Some("serve") => {
             let config = envy::from_env::<Config>().expect("error loading config");
             init_tracing(&config);

crates/editor/src/items.rs 🔗

@@ -185,6 +185,14 @@ impl FollowableItem for Editor {
 
     fn to_state_proto(&self, cx: &WindowContext) -> Option<proto::view::Variant> {
         let buffer = self.buffer.read(cx);
+        if buffer
+            .as_singleton()
+            .and_then(|buffer| buffer.read(cx).file())
+            .map_or(false, |file| file.is_private())
+        {
+            return None;
+        }
+
         let scroll_anchor = self.scroll_manager.anchor();
         let excerpts = buffer
             .read(cx)

crates/language/src/buffer.rs 🔗

@@ -384,7 +384,7 @@ pub trait File: Send + Sync {
     /// Converts this file into a protobuf message.
     fn to_proto(&self) -> rpc::proto::File;
 
-    /// Return whether Zed considers this to be a dotenv file.
+    /// Return whether Zed considers this to be a private file.
     fn is_private(&self) -> bool;
 }
 
@@ -406,6 +406,11 @@ pub trait LocalFile: File {
         mtime: SystemTime,
         cx: &mut AppContext,
     );
+
+    /// Returns true if the file should not be shared with collaborators.
+    fn is_private(&self, _: &AppContext) -> bool {
+        false
+    }
 }
 
 /// The auto-indent behavior associated with an editing operation.

crates/project/src/project.rs 🔗

@@ -56,6 +56,7 @@ use postage::watch;
 use prettier_support::{DefaultPrettier, PrettierInstance};
 use project_settings::{LspSettings, ProjectSettings};
 use rand::prelude::*;
+use rpc::{ErrorCode, ErrorExt};
 use search::SearchQuery;
 use serde::Serialize;
 use settings::{Settings, SettingsStore};
@@ -1760,7 +1761,7 @@ impl Project {
         cx.background_executor().spawn(async move {
             wait_for_loading_buffer(loading_watch)
                 .await
-                .map_err(|error| anyhow!("{project_path:?} opening failure: {error:#}"))
+                .map_err(|e| e.cloned())
         })
     }
 
@@ -8011,11 +8012,20 @@ impl Project {
             .update(&mut cx, |this, cx| this.open_buffer_for_symbol(&symbol, cx))?
             .await?;
 
-        Ok(proto::OpenBufferForSymbolResponse {
-            buffer_id: this.update(&mut cx, |this, cx| {
-                this.create_buffer_for_peer(&buffer, peer_id, cx).into()
-            })?,
-        })
+        this.update(&mut cx, |this, cx| {
+            let is_private = buffer
+                .read(cx)
+                .file()
+                .map(|f| f.is_private())
+                .unwrap_or_default();
+            if is_private {
+                Err(anyhow!(ErrorCode::UnsharedItem))
+            } else {
+                Ok(proto::OpenBufferForSymbolResponse {
+                    buffer_id: this.create_buffer_for_peer(&buffer, peer_id, cx).into(),
+                })
+            }
+        })?
     }
 
     fn symbol_signature(&self, project_path: &ProjectPath) -> [u8; 32] {
@@ -8037,11 +8047,7 @@ impl Project {
         let buffer = this
             .update(&mut cx, |this, cx| this.open_buffer_by_id(buffer_id, cx))?
             .await?;
-        this.update(&mut cx, |this, cx| {
-            Ok(proto::OpenBufferResponse {
-                buffer_id: this.create_buffer_for_peer(&buffer, peer_id, cx).into(),
-            })
-        })?
+        Project::respond_to_open_buffer_request(this, buffer, peer_id, &mut cx)
     }
 
     async fn handle_open_buffer_by_path(
@@ -8063,10 +8069,28 @@ impl Project {
         })?;
 
         let buffer = open_buffer.await?;
-        this.update(&mut cx, |this, cx| {
-            Ok(proto::OpenBufferResponse {
-                buffer_id: this.create_buffer_for_peer(&buffer, peer_id, cx).into(),
-            })
+        Project::respond_to_open_buffer_request(this, buffer, peer_id, &mut cx)
+    }
+
+    fn respond_to_open_buffer_request(
+        this: Model<Self>,
+        buffer: Model<Buffer>,
+        peer_id: proto::PeerId,
+        cx: &mut AsyncAppContext,
+    ) -> Result<proto::OpenBufferResponse> {
+        this.update(cx, |this, cx| {
+            let is_private = buffer
+                .read(cx)
+                .file()
+                .map(|f| f.is_private())
+                .unwrap_or_default();
+            if is_private {
+                Err(anyhow!(ErrorCode::UnsharedItem))
+            } else {
+                Ok(proto::OpenBufferResponse {
+                    buffer_id: this.create_buffer_for_peer(&buffer, peer_id, cx).into(),
+                })
+            }
         })?
     }
 

crates/project_panel/Cargo.toml 🔗

@@ -31,6 +31,7 @@ theme = { path = "../theme" }
 ui = { path = "../ui" }
 unicase = "2.6"
 util = { path = "../util" }
+client = { path = "../client" }
 workspace = { path = "../workspace", package = "workspace" }
 
 [dev-dependencies]

crates/project_panel/src/project_panel.rs 🔗

@@ -1,5 +1,6 @@
 pub mod file_associations;
 mod project_panel_settings;
+use client::{ErrorCode, ErrorExt};
 use settings::Settings;
 
 use db::kvp::KEY_VALUE_STORE;
@@ -35,6 +36,7 @@ use unicase::UniCase;
 use util::{maybe, ResultExt, TryFutureExt};
 use workspace::{
     dock::{DockPosition, Panel, PanelEvent},
+    notifications::DetachAndPromptErr,
     Workspace,
 };
 
@@ -259,6 +261,7 @@ impl ProjectPanel {
                 } => {
                     if let Some(worktree) = project.read(cx).worktree_for_entry(entry_id, cx) {
                         if let Some(entry) = worktree.read(cx).entry_for_id(entry_id) {
+                            let file_path = entry.path.clone();
                             workspace
                                 .open_path(
                                     ProjectPath {
@@ -269,7 +272,15 @@ impl ProjectPanel {
                                     focus_opened_item,
                                     cx,
                                 )
-                                .detach_and_log_err(cx);
+                                .detach_and_prompt_err("Failed to open file", cx, move |e, _| {
+                                    match e.error_code() {
+                                        ErrorCode::UnsharedItem => Some(format!(
+                                            "{} is not shared by the host. This could be because it has been marked as `private`",
+                                            file_path.display()
+                                        )),
+                                        _ => None,
+                                    }
+                                });
                             if !focus_opened_item {
                                 if let Some(project_panel) = project_panel.upgrade() {
                                     let focus_handle = project_panel.read(cx).focus_handle.clone();

crates/rpc/proto/zed.proto 🔗

@@ -216,6 +216,7 @@ enum ErrorCode {
     BadPublicNesting = 9;
     CircularNesting = 10;
     WrongMoveTarget = 11;
+    UnsharedItem = 12;
 }
 
 message Test {

crates/rpc/src/error.rs 🔗

@@ -80,6 +80,8 @@ pub trait ErrorExt {
     fn error_tag(&self, k: &str) -> Option<&str>;
     /// to_proto() converts the error into a proto::Error
     fn to_proto(&self) -> proto::Error;
+    ///
+    fn cloned(&self) -> anyhow::Error;
 }
 
 impl ErrorExt for anyhow::Error {
@@ -106,6 +108,14 @@ impl ErrorExt for anyhow::Error {
             ErrorCode::Internal.message(format!("{}", self)).to_proto()
         }
     }
+
+    fn cloned(&self) -> anyhow::Error {
+        if let Some(rpc_error) = self.downcast_ref::<RpcError>() {
+            rpc_error.cloned()
+        } else {
+            anyhow::anyhow!("{}", self)
+        }
+    }
 }
 
 impl From<proto::ErrorCode> for anyhow::Error {
@@ -189,6 +199,10 @@ impl ErrorExt for RpcError {
             tags: self.tags.clone(),
         }
     }
+
+    fn cloned(&self) -> anyhow::Error {
+        self.clone().into()
+    }
 }
 
 impl std::error::Error for RpcError {

crates/workspace/src/pane_group.rs 🔗

@@ -176,11 +176,19 @@ impl Member {
                     return div().into_any();
                 }
 
-                let leader = follower_states.get(pane).and_then(|state| {
+                let follower_state = follower_states.get(pane);
+
+                let leader = follower_state.and_then(|state| {
                     let room = active_call?.read(cx).room()?.read(cx);
                     room.remote_participant_for_peer_id(state.leader_id)
                 });
 
+                let is_in_unshared_view = follower_state.map_or(false, |state| {
+                    state.active_view_id.is_some_and(|view_id| {
+                        !state.items_by_leader_view_id.contains_key(&view_id)
+                    })
+                });
+
                 let mut leader_border = None;
                 let mut leader_status_box = None;
                 let mut leader_join_data = None;
@@ -198,7 +206,14 @@ impl Member {
                             project_id: leader_project_id,
                         } => {
                             if Some(leader_project_id) == project.read(cx).remote_id() {
-                                None
+                                if is_in_unshared_view {
+                                    Some(Label::new(format!(
+                                        "{} is in an unshared pane",
+                                        leader.user.github_login
+                                    )))
+                                } else {
+                                    None
+                                }
                             } else {
                                 leader_join_data = Some((leader_project_id, leader.user.id));
                                 Some(Label::new(format!(