Skip to content
12 changes: 9 additions & 3 deletions fileglancer/filestore.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
56 changes: 55 additions & 1 deletion frontend/src/__tests__/componentTests/ChangePermissions.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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' });

Expand All @@ -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' });
Expand Down
81 changes: 78 additions & 3 deletions frontend/src/components/ui/Dialogs/ChangePermissions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ export default function ChangePermissions({
</th>
<th className="px-3 py-2 text-left font-medium">Read</th>
<th className="px-3 py-2 text-left font-medium">Write</th>
<th className="px-3 py-2 text-left font-medium">Execute</th>
</tr>
</thead>

Expand All @@ -73,7 +74,7 @@ export default function ChangePermissions({
<td className="p-3 font-medium">
Owner: {fileBrowserState.propertiesTarget.owner}
</td>
{/* Owner read/write */}
{/* Owner read/write/execute */}
<td className="p-3">
<input
aria-label="r_1"
Expand All @@ -93,13 +94,26 @@ export default function ChangePermissions({
type="checkbox"
/>
</td>
<td className="p-3">
<input
aria-label="x_3"
checked={
localPermissions[3] === 'x' ||
localPermissions[3] === 's'
}
className="accent-secondary-light hover:cursor-pointer"
name="x_3"
onChange={event => handleLocalPermissionChange(event)}
type="checkbox"
/>
</td>
</tr>

<tr className="border-b border-surface dark:border-surface-light">
<td className="p-3 font-medium">
Group: {fileBrowserState.propertiesTarget.group}
</td>
{/* Group read/write */}
{/* Group read/write/execute */}
<td className="p-3">
<input
aria-label="r_4"
Expand All @@ -120,11 +134,24 @@ export default function ChangePermissions({
type="checkbox"
/>
</td>
<td className="p-3">
<input
aria-label="x_6"
checked={
localPermissions[6] === 'x' ||
localPermissions[6] === 's'
}
className="accent-secondary-light hover:cursor-pointer"
name="x_6"
onChange={event => handleLocalPermissionChange(event)}
type="checkbox"
/>
</td>
</tr>

<tr>
<td className="p-3 font-medium">Everyone else</td>
{/* Everyone else read/write */}
{/* Everyone else read/write/execute */}
<td className="p-3">
<input
aria-label="r_7"
Expand All @@ -145,10 +172,58 @@ export default function ChangePermissions({
type="checkbox"
/>
</td>
<td className="p-3">
<input
aria-label="x_9"
checked={
localPermissions[9] === 'x' ||
localPermissions[9] === 't'
}
className="accent-secondary-light hover:cursor-pointer"
name="x_9"
onChange={event => handleLocalPermissionChange(event)}
type="checkbox"
/>
</td>
</tr>
</tbody>
) : null}
</table>

{fileBrowserState.propertiesTarget.is_dir && localPermissions ? (
<>
<label className="flex items-start gap-2 my-4 text-sm text-foreground">
<input
aria-label="s_6"
checked={
localPermissions[6] === 's' || localPermissions[6] === 'S'
}
className="accent-secondary-light hover:cursor-pointer mt-0.5"
name="s_6"
onChange={event => handleLocalPermissionChange(event)}
type="checkbox"
/>
<span>
New files created in this directory belong to group{' '}
<em>{fileBrowserState.propertiesTarget.group}</em>, regardless
of the file creator's primary group
</span>
</label>
<label className="flex items-center gap-2 my-4 text-sm text-foreground">
<input
aria-label="t_9"
checked={
localPermissions[9] === 't' || localPermissions[9] === 'T'
}
className="accent-secondary-light hover:cursor-pointer"
name="t_9"
onChange={event => handleLocalPermissionChange(event)}
type="checkbox"
/>
Only owner can delete and rename files in this directory
</label>
</>
) : null}
<Button
className="!rounded-md"
disabled={Boolean(
Expand Down
Loading
Loading