From f54aebae9072ac2bfa2dc066a647eb30322a461c Mon Sep 17 00:00:00 2001 From: Allison Truhlar Date: Thu, 23 Apr 2026 15:39:41 -0400 Subject: [PATCH 1/8] feat: support execute and sticky bit in backend permission handling --- fileglancer/filestore.py | 12 +++++++++--- tests/test_filestore.py | 20 ++++++++++++++++++++ 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/fileglancer/filestore.py b/fileglancer/filestore.py index ce3417d6..553ad20f 100644 --- a/fileglancer/filestore.py +++ b/fileglancer/filestore.py @@ -748,17 +748,23 @@ def change_file_permissions(self, path: str, permissions: str): raise ValueError("Permissions must be a string of length 10") full_path = self._check_path_in_root(path) # Convert permission string (like '-rw-r--r--') to octal mode + # Execute positions (3, 6, 9) can contain special characters: + # 'x' = execute, 's'/'S' = setuid/setgid, 't'/'T' = sticky bit + # Lowercase = execute set, uppercase = execute not set mode = 0 # Owner permissions (positions 1-3) if permissions[1] == 'r': mode |= stat.S_IRUSR if permissions[2] == 'w': mode |= stat.S_IWUSR - if permissions[3] == 'x': mode |= stat.S_IXUSR + if permissions[3] in ('x', 's'): mode |= stat.S_IXUSR + if permissions[3] in ('s', 'S'): mode |= stat.S_ISUID # Group permissions (positions 4-6) if permissions[4] == 'r': mode |= stat.S_IRGRP if permissions[5] == 'w': mode |= stat.S_IWGRP - if permissions[6] == 'x': mode |= stat.S_IXGRP + if permissions[6] in ('x', 's'): mode |= stat.S_IXGRP + if permissions[6] in ('s', 'S'): mode |= stat.S_ISGID # Other permissions (positions 7-9) if permissions[7] == 'r': mode |= stat.S_IROTH if permissions[8] == 'w': mode |= stat.S_IWOTH - if permissions[9] == 'x': mode |= stat.S_IXOTH + if permissions[9] in ('x', 't'): mode |= stat.S_IXOTH + if permissions[9] in ('t', 'T'): mode |= stat.S_ISVTX os.chmod(full_path, mode) diff --git a/tests/test_filestore.py b/tests/test_filestore.py index 1e7b0dd0..5d597525 100644 --- a/tests/test_filestore.py +++ b/tests/test_filestore.py @@ -200,6 +200,26 @@ def test_change_file_permissions(filestore, test_dir): assert stat.S_IMODE(os.stat(fullpath).st_mode) == 0o644 +def test_change_file_permissions_with_execute(filestore, test_dir): + filestore.change_file_permissions("test.txt", "-rwxr-xr-x") + fullpath = os.path.join(test_dir, "test.txt") + assert stat.S_IMODE(os.stat(fullpath).st_mode) == 0o755 + + +def test_change_dir_permissions_with_sticky_bit(filestore, test_dir): + subdir = os.path.join(test_dir, "sticky_dir") + os.makedirs(subdir, exist_ok=True) + filestore.change_file_permissions("sticky_dir", "drwxrwxrwt") + assert stat.S_IMODE(os.stat(subdir).st_mode) == 0o1777 + + +def test_change_dir_permissions_sticky_without_execute(filestore, test_dir): + subdir = os.path.join(test_dir, "sticky_dir2") + os.makedirs(subdir, exist_ok=True) + filestore.change_file_permissions("sticky_dir2", "drwxrwxrwT") + assert stat.S_IMODE(os.stat(subdir).st_mode) == 0o1776 + + def test_change_file_permissions_invalid_permissions(filestore): with pytest.raises(ValueError): filestore.change_file_permissions("test.txt", "invalid") From 0cef28bd3fb447ee699cc6230c78c4c2fcdf5b05 Mon Sep 17 00:00:00 2001 From: Allison Truhlar Date: Thu, 23 Apr 2026 15:39:58 -0400 Subject: [PATCH 2/8] feat: add execute and sticky bit controls to permissions UI --- .../componentTests/ChangePermissions.test.tsx | 34 +++++++++- .../ui/Dialogs/ChangePermissions.tsx | 62 +++++++++++++++++- .../ui/PropertiesDrawer/PermissionsTable.tsx | 23 ++++++- frontend/src/hooks/usePermissionsDialog.ts | 63 ++++++++++++++++--- frontend/src/utils/index.ts | 24 ++++++- 5 files changed, 187 insertions(+), 19 deletions(-) diff --git a/frontend/src/__tests__/componentTests/ChangePermissions.test.tsx b/frontend/src/__tests__/componentTests/ChangePermissions.test.tsx index 04830fa6..a78826df 100644 --- a/frontend/src/__tests__/componentTests/ChangePermissions.test.tsx +++ b/frontend/src/__tests__/componentTests/ChangePermissions.test.tsx @@ -73,7 +73,7 @@ describe('Change Permissions dialog', () => { expect(submitButton).toBeDisabled(); }); - it('should update local permissions when input is checked', async () => { + it('should update local permissions when write input is checked', async () => { const user = userEvent.setup(); const checkbox = screen.getByRole('checkbox', { name: 'w_8' }); @@ -85,6 +85,38 @@ describe('Change Permissions dialog', () => { expect(checkbox).toBeChecked(); }); + it('should update local permissions when execute input is toggled', async () => { + const user = userEvent.setup(); + // Owner execute is at position 3 - initial 'drwxrwxr-x' has 'x' at position 3 + const checkbox = screen.getByRole('checkbox', { name: 'x_3' }); + + expect(checkbox).toBeChecked(); + await user.click(checkbox); + expect(checkbox).not.toBeChecked(); + }); + + it('should show sticky bit checkbox for directories', () => { + const stickyCheckbox = screen.getByRole('checkbox', { name: 't_9' }); + expect(stickyCheckbox).toBeInTheDocument(); + // Initial 'drwxrwxr-x' does not have sticky bit + expect(stickyCheckbox).not.toBeChecked(); + }); + + it('should toggle sticky bit while preserving execute', async () => { + const user = userEvent.setup(); + const stickyCheckbox = screen.getByRole('checkbox', { name: 't_9' }); + const executeCheckbox = screen.getByRole('checkbox', { name: 'x_9' }); + + // Initial 'drwxrwxr-x' has execute at position 9 + expect(executeCheckbox).toBeChecked(); + expect(stickyCheckbox).not.toBeChecked(); + + // Enable sticky bit - should change 'x' to 't' (sticky + execute) + await user.click(stickyCheckbox); + expect(stickyCheckbox).toBeChecked(); + expect(executeCheckbox).toBeChecked(); // execute preserved + }); + it('calls toast.success for an ok HTTP response', async () => { const user = userEvent.setup(); const checkbox = screen.getByRole('checkbox', { name: 'w_8' }); diff --git a/frontend/src/components/ui/Dialogs/ChangePermissions.tsx b/frontend/src/components/ui/Dialogs/ChangePermissions.tsx index 6994366d..28f904a4 100644 --- a/frontend/src/components/ui/Dialogs/ChangePermissions.tsx +++ b/frontend/src/components/ui/Dialogs/ChangePermissions.tsx @@ -64,6 +64,7 @@ export default function ChangePermissions({ Read Write + Execute @@ -73,7 +74,7 @@ export default function ChangePermissions({ Owner: {fileBrowserState.propertiesTarget.owner} - {/* Owner read/write */} + {/* Owner read/write/execute */} + + handleLocalPermissionChange(event)} + type="checkbox" + /> + Group: {fileBrowserState.propertiesTarget.group} - {/* Group read/write */} + {/* Group read/write/execute */} + + handleLocalPermissionChange(event)} + type="checkbox" + /> + Everyone else - {/* Everyone else read/write */} + {/* Everyone else read/write/execute */} + + handleLocalPermissionChange(event)} + type="checkbox" + /> + ) : null} + + {fileBrowserState.propertiesTarget.is_dir && localPermissions ? ( + + ) : null}