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/frontend/src/__tests__/componentTests/ChangePermissions.test.tsx b/frontend/src/__tests__/componentTests/ChangePermissions.test.tsx
index 04830fa6..66b7f89f 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,60 @@ 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('should show setgid checkbox for directories', () => {
+ const setgidCheckbox = screen.getByRole('checkbox', { name: 's_6' });
+ expect(setgidCheckbox).toBeInTheDocument();
+ // Initial 'drwxrwxr-x' does not have setgid
+ expect(setgidCheckbox).not.toBeChecked();
+ });
+
+ it('should toggle setgid while preserving group execute', async () => {
+ const user = userEvent.setup();
+ const setgidCheckbox = screen.getByRole('checkbox', { name: 's_6' });
+ const groupExecuteCheckbox = screen.getByRole('checkbox', { name: 'x_6' });
+
+ // Initial 'drwxrwxr-x' has group execute at position 6
+ expect(groupExecuteCheckbox).toBeChecked();
+ expect(setgidCheckbox).not.toBeChecked();
+
+ // Enable setgid - should change 'x' to 's' (setgid + execute)
+ await user.click(setgidCheckbox);
+ expect(setgidCheckbox).toBeChecked();
+ expect(groupExecuteCheckbox).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..63573cb4 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}