Repository: electron/electron
Pull request: feat: emit an event when accessing restricted path in File System Access API

#### Description of Change

Closes https://github.com/electron/electron/issues/42459

More closely match upstream spec behavior when File System Access API attempts to open a file or directory in a blocked path. Now, a `file-system-access-restricted` event is emitted on `session` when a user attempts to access a restricted path:

```js
{
  origin: 'https://www.buzzfeed.com/',
  isDirectory: true,
  path: '/Users/codebytere/Downloads'
}
```

Developers can then decide how to proceed.

#### Checklist
<!-- Remove items that do not apply. For completed items, change [ ] to [x]. -->

- [x] PR description included and stakeholders cc'd
- [x] `npm test` passes
- [x] [PR release notes](https://github.com/electron/clerk/blob/main/README.md) describe the change in a way relevant to app developers, and are [capitalized, punctuated, and past tense](https://github.com/electron/clerk/blob/main/README.md#examples).

#### Release Notes

Notes: Aligned failure pathway in File System Access API with upstream when attempting to open a file or directory in a blocked path. 


Changed files:
filename: docs/api/session.md
patch: @@ -143,6 +143,71 @@ Returns:
 Emitted after an extension is loaded and all necessary browser state is
 initialized to support the start of the extension's background page.
 
+#### Event: 'file-system-access-restricted'
+
+Returns:
+
+* `event` Event
+* `details` Object
+  * `origin` string - The origin that initiated access to the blocked path.
+  * `isDirectory` boolean - Whether or not the path is a directory.
+  * `path` string - The blocked path attempting to be accessed.
+* `callback` Function
+  * `action` string - The action to take as a result of the restricted path access attempt.
+    * `allow` - This will allow `path` to be accessed despite restricted status.
+    * `deny` - This will block the access request and trigger an [`AbortError`](https://developer.mozilla.org/en-US/docs/Web/API/AbortController/abort).
+    * `tryAgain` - This will open a new file picker and allow the user to choose another path.
+
+```js
+const { app, dialog, BrowserWindow, session } = require('electron')
+
+async function createWindow () {
+  const mainWindow = new BrowserWindow()
+
+  await mainWindow.loadURL('https://buzzfeed.com')
+
+  session.defaultSession.on('file-system-access-restricted', async (e, details, callback) => {
+    const { origin, path } = details
+    const { response } = await dialog.showMessageBox({
+      message: `Are you sure you want ${origin} to open restricted path ${path}?`,
+      title: 'File System Access Restricted',
+      buttons: ['Choose a different folder', 'Allow', 'Cancel'],
+      cancelId: 2
+    })
+
+    if (response === 0) {
+      callback('tryAgain')
+    } else if (response === 1) {
+      callback('allow')
+    } else {
+      callback('deny')
+    }
+  })
+
+  mainWindow.webContents.executeJavaScript(`
+    window.showDirectoryPicker({
+      id: 'electron-demo',
+      mode: 'readwrite',
+      startIn: 'downloads',
+    }).catch(e => {
+      console.log(e)
+    })`, true
+  )
+}
+
+app.whenReady().then(() => {
+  createWindow()
+
+  app.on('activate', () => {
+    if (BrowserWindow.getAllWindows().length === 0) createWindow()
+  })
+})
+
+app.on('window-all-closed', function () {
+  if (process.platform !== 'darwin') app.quit()
+})
+```
+
 #### Event: 'preconnect'
 
 Returns:

filename: shell/browser/file_system_access/file_system_access_permission_context.cc
patch: @@ -11,6 +11,7 @@
 #include "base/files/file_path.h"
 #include "base/json/values_util.h"
 #include "base/path_service.h"
+#include "base/task/bind_post_task.h"
 #include "base/task/thread_pool.h"
 #include "base/time/time.h"
 #include "base/values.h"
@@ -25,18 +26,52 @@
 #include "content/public/browser/render_frame_host.h"
 #include "content/public/browser/render_process_host.h"
 #include "content/public/browser/web_contents.h"
+#include "gin/data_object_builder.h"
+#include "shell/browser/api/electron_api_session.h"
 #include "shell/browser/electron_permission_manager.h"
 #include "shell/browser/web_contents_permission_helper.h"
+#include "shell/common/gin_converters/callback_converter.h"
 #include "shell/common/gin_converters/file_path_converter.h"
 #include "third_party/blink/public/mojom/file_system_access/file_system_access_manager.mojom.h"
 #include "ui/base/l10n/l10n_util.h"
 #include "url/origin.h"
 
+namespace gin {
+
+template <>
+struct Converter<
+    ChromeFileSystemAccessPermissionContext::SensitiveEntryResult> {
+  static bool FromV8(
+      v8::Isolate* isolate,
+      v8::Local<v8::Value> val,
+      ChromeFileSystemAccessPermissionContext::SensitiveEntryResult* out) {
+    std::string type;
+    if (!ConvertFromV8(isolate, val, &type))
+      return false;
+    if (type == "allow")
+      *out = ChromeFileSystemAccessPermissionContext::SensitiveEntryResult::
+          kAllowed;
+    else if (type == "tryAgain")
+      *out = ChromeFileSystemAccessPermissionContext::SensitiveEntryResult::
+          kTryAgain;
+    else if (type == "deny")
+      *out =
+          ChromeFileSystemAccessPermissionContext::SensitiveEntryResult::kAbort;
+    else
+      return false;
+    return true;
+  }
+};
+
+}  // namespace gin
+
 namespace {
 
 using BlockType = ChromeFileSystemAccessPermissionContext::BlockType;
 using HandleType = content::FileSystemAccessPermissionContext::HandleType;
 using GrantType = electron::FileSystemAccessPermissionContext::GrantType;
+using SensitiveEntryResult =
+    ChromeFileSystemAccessPermissionContext::SensitiveEntryResult;
 using blink::mojom::PermissionStatus;
 
 // Dictionary keys for the FILE_SYSTEM_LAST_PICKED_DIRECTORY website setting.
@@ -526,11 +561,11 @@ void FileSystemAccessPermissionContext::ConfirmSensitiveEntryAccess(
     content::GlobalRenderFrameHostId frame_id,
     base::OnceCallback<void(SensitiveEntryResult)> callback) {
   DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_);
+  callback_ = std::move(callback);
 
   auto after_blocklist_check_callback = base::BindOnce(
       &FileSystemAccessPermissionContext::DidCheckPathAgainstBlocklist,
-      GetWeakPtr(), origin, path, handle_type, user_action, frame_id,
-      std::move(callback));
+      GetWeakPtr(), origin, path, handle_type, user_action, frame_id);
   CheckPathAgainstBlocklist(path_type, path, handle_type,
                             std::move(after_blocklist_check_callback));
 }
@@ -569,31 +604,54 @@ void FileSystemAccessPermissionContext::PerformAfterWriteChecks(
   std::move(callback).Run(AfterWriteCheckResult::kAllow);
 }
 
+void FileSystemAccessPermissionContext::RunRestrictedPathCallback(
+    SensitiveEntryResult result) {
+  if (callback_)
+    std::move(callback_).Run(result);
+}
+
+void FileSystemAccessPermissionContext::OnRestrictedPathResult(
+    gin::Arguments* args) {
+  SensitiveEntryResult result = SensitiveEntryResult::kAbort;
+  args->GetNext(&result);
+  RunRestrictedPathCallback(result);
+}
+
 void FileSystemAccessPermissionContext::DidCheckPathAgainstBlocklist(
     const url::Origin& origin,
     const base::FilePath& path,
     HandleType handle_type,
     UserAction user_action,
     content::GlobalRenderFrameHostId frame_id,
-    base::OnceCallback<void(SensitiveEntryResult)> callback,
     bool should_block) {
   DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_);
 
   if (user_action == UserAction::kNone) {
-    std::move(callback).Run(should_block ? SensitiveEntryResult::kAbort
-                                         : SensitiveEntryResult::kAllowed);
+    RunRestrictedPathCallback(should_block ? SensitiveEntryResult::kAbort
+                                           : SensitiveEntryResult::kAllowed);
     return;
   }
 
-  // Chromium opens a dialog here, but in Electron's case we log and abort.
   if (should_block) {
-    LOG(INFO) << path.value()
-              << " is blocked by the blocklis and cannot be accessed";
-    std::move(callback).Run(SensitiveEntryResult::kAbort);
+    auto* session =
+        electron::api::Session::FromBrowserContext(browser_context());
+    v8::Isolate* isolate = JavascriptEnvironment::GetIsolate();
+    v8::HandleScope scope(isolate);
+    v8::Local<v8::Object> details =
+        gin::DataObjectBuilder(isolate)
+            .Set("origin", origin.GetURL().spec())
+            .Set("isDirectory", handle_type == HandleType::kDirectory)
+            .Set("path", path)
+            .Build();
+    session->Emit(
+        "file-system-access-restricted", details,
+        base::BindRepeating(
+            &FileSystemAccessPermissionContext::OnRestrictedPathResult,
+            weak_factory_.GetWeakPtr()));
     return;
   }
 
-  std::move(callback).Run(SensitiveEntryResult::kAllowed);
+  RunRestrictedPathCallback(SensitiveEntryResult::kAllowed);
 }
 
 void FileSystemAccessPermissionContext::MaybeEvictEntries(

filename: shell/browser/file_system_access/file_system_access_permission_context.h
patch: @@ -21,6 +21,10 @@
 
 class GURL;
 
+namespace gin {
+class Arguments;
+}  // namespace gin
+
 namespace base {
 class FilePath;
 }  // namespace base
@@ -128,14 +132,16 @@ class FileSystemAccessPermissionContext
                                  const base::FilePath& path,
                                  HandleType handle_type,
                                  base::OnceCallback<void(bool)> callback);
-  void DidCheckPathAgainstBlocklist(
-      const url::Origin& origin,
-      const base::FilePath& path,
-      HandleType handle_type,
-      UserAction user_action,
-      content::GlobalRenderFrameHostId frame_id,
-      base::OnceCallback<void(SensitiveEntryResult)> callback,
-      bool should_block);
+  void DidCheckPathAgainstBlocklist(const url::Origin& origin,
+                                    const base::FilePath& path,
+                                    HandleType handle_type,
+                                    UserAction user_action,
+                                    content::GlobalRenderFrameHostId frame_id,
+                                    bool should_block);
+
+  void RunRestrictedPathCallback(SensitiveEntryResult result);
+
+  void OnRestrictedPathResult(gin::Arguments* args);
 
   void MaybeEvictEntries(base::Value::Dict& dict);
 
@@ -159,6 +165,8 @@ class FileSystemAccessPermissionContext
 
   std::map<url::Origin, base::Value::Dict> id_pathinfo_map_;
 
+  base::OnceCallback<void(SensitiveEntryResult)> callback_;
+
   base::WeakPtrFactory<FileSystemAccessPermissionContext> weak_factory_{this};
 };