Stop invalidating sudo timestamp on mode exit

This commit is contained in:
Philip Henning 2026-09-03 19:28:01 +02:00
parent 026bbc4eff
commit 68a9696e7d
6 changed files with 55 additions and 43 deletions

View file

@ -63,6 +63,8 @@ repository documents.
- The application starts in Read-only Mode.
- Mutating actions require Privileged Mode, and a Pre-edit Backup must exist
before writes are enabled.
- Leaving Privileged Mode clears only application permission and undo/redo
session state; it must not alter sudo's cached timestamp.
- Default Entries are protected from mutation and must remain first when file
order changes.
- All privileged writes go through `HostsManager`.

View file

@ -83,8 +83,8 @@ sorting, DNS refresh, movement, undo, and redo.
unchanged.
- Pre-edit Backups are not listed or restored by the TUI and have no retention
management. Manual recovery is documented in the user guide.
- Leaving Privileged Mode currently invalidates the user's cached sudo
timestamp. This is [tracked for removal](https://git.s1q.dev/phg/hosts/issues/4).
- Leaving Privileged Mode clears the application's session state but does not
alter the user's cached sudo timestamp.
- Serialization preserves Host Entries and comments semantically, but
normalizes spacing, comment placement, and blank lines. It is not a
byte-for-byte round trip.

View file

@ -176,11 +176,8 @@ selection of the newest backup without inspecting it.
Press `Ctrl+E` again. The application clears its undo/redo history and forgets
which Pre-edit Backup belongs to the session. The backup file remains in the
temporary directory.
Leaving Privileged Mode currently runs `sudo -k`, which invalidates your cached
sudo timestamp for other terminal sessions as well. This behavior is
[tracked for removal](https://git.s1q.dev/phg/hosts/issues/4).
temporary directory. It does not alter your cached sudo timestamp, including
authorization used by other terminal sessions.
## Configuration

View file

@ -27,7 +27,7 @@ class PermissionManager:
"""
Manages sudo permissions for hosts file editing.
Handles requesting, validating, and releasing elevated permissions
Handles requesting, validating, and tracking the elevated permissions
needed for modifying the system hosts file.
"""
@ -101,13 +101,8 @@ class PermissionManager:
except Exception:
return False
def release_sudo(self) -> None:
"""Release sudo permissions."""
try:
subprocess.run(["sudo", "-k"], capture_output=True, timeout=5)
except Exception:
pass
finally:
def clear_permission_state(self) -> None:
"""Clear application permission state without changing sudo's timestamp."""
self.has_sudo = False
self._sudo_validated = False
@ -170,7 +165,7 @@ class HostsManager:
def exit_edit_mode(self) -> Tuple[bool, str]:
"""
Exit edit mode and release permissions.
Exit edit mode and clear application permission state.
Returns:
Tuple of (success, message)
@ -179,7 +174,7 @@ class HostsManager:
return True, "Already in read-only mode"
try:
self.permission_manager.release_sudo()
self.permission_manager.clear_permission_state()
self.edit_mode = False
self._backup_path = None
self.undo_redo_history.clear_history() # Clear undo/redo history when exiting edit mode

View file

@ -1,7 +1,7 @@
"""Tests for application-level authorization flow."""
from contextlib import contextmanager
from unittest.mock import Mock
from unittest.mock import Mock, patch
from textual.app import SuspendNotSupported
@ -125,3 +125,22 @@ class TestPrivilegedModeAuthorization:
app.update_status.assert_called_once_with(
"Interactive authorization requires terminal suspension; remaining in Read-only Mode."
)
@patch("src.hosts.core.manager.subprocess.run")
def test_leaving_privileged_mode_preserves_the_sudo_timestamp(self, mock_run):
"""Test Ctrl+E clears app state without invalidating sudo globally."""
app = HostsManagerApp()
app.edit_mode = True
app.manager.edit_mode = True
app.manager.permission_manager.has_sudo = True
app.manager.permission_manager._sudo_validated = True
app.update_status = Mock()
app.action_toggle_edit_mode()
assert not app.edit_mode
assert not app.manager.edit_mode
assert not app.manager.permission_manager.has_sudo
assert not app.manager.permission_manager._sudo_validated
mock_run.assert_not_called()
app.update_status.assert_called_once_with("Edit mode disabled")

View file

@ -12,6 +12,7 @@ from pathlib import Path
from unittest.mock import Mock, patch
from src.hosts.core.manager import PermissionManager, HostsManager
from src.hosts.core.models import HostEntry, HostsFile
from src.hosts.core.commands import ToggleEntryCommand
class TestPermissionManager:
@ -183,32 +184,17 @@ class TestPermissionManager:
assert not result
@patch("subprocess.run")
def test_release_sudo(self, mock_run):
"""Test releasing sudo permissions."""
def test_clear_permission_state_preserves_global_sudo_timestamp(self, mock_run):
"""Test exiting Privileged Mode preserves the global sudo timestamp."""
pm = PermissionManager()
pm.has_sudo = True
pm._sudo_validated = True
pm.release_sudo()
pm.clear_permission_state()
assert not pm.has_sudo
assert not pm._sudo_validated
mock_run.assert_called_once_with(["sudo", "-k"], capture_output=True, timeout=5)
@patch("subprocess.run")
def test_release_sudo_exception(self, mock_run):
"""Test releasing sudo with exception."""
mock_run.side_effect = Exception("Test error")
pm = PermissionManager()
pm.has_sudo = True
pm._sudo_validated = True
pm.release_sudo()
# Should still reset state even if command fails
assert not pm.has_sudo
assert not pm._sudo_validated
mock_run.assert_not_called()
class TestHostsManager:
@ -306,14 +292,25 @@ class TestHostsManager:
assert not manager.edit_mode
def test_exit_edit_mode_success(self):
"""Test exiting edit mode successfully."""
"""Test exit clears the Privileged Mode session state."""
with tempfile.NamedTemporaryFile() as temp_file:
manager = HostsManager(temp_file.name)
manager.edit_mode = True
manager._backup_path = Path("/tmp/backup")
hosts_file = HostsFile(
entries=[
HostEntry("192.0.2.1", ["example.test"]),
HostEntry("192.0.2.2", ["example-two.test"]),
]
)
manager.undo_redo_history.execute_command(ToggleEntryCommand(0), hosts_file)
manager.undo_redo_history.execute_command(ToggleEntryCommand(1), hosts_file)
manager.undo_redo_history.undo(hosts_file)
assert manager.can_undo()
assert manager.can_redo()
# Mock permission manager
manager.permission_manager.release_sudo = Mock()
manager.permission_manager.clear_permission_state = Mock()
success, message = manager.exit_edit_mode()
@ -321,7 +318,9 @@ class TestHostsManager:
assert "disabled" in message
assert not manager.edit_mode
assert manager._backup_path is None
manager.permission_manager.release_sudo.assert_called_once()
assert not manager.can_undo()
assert not manager.can_redo()
manager.permission_manager.clear_permission_state.assert_called_once()
def test_exit_edit_mode_not_in_edit(self):
"""Test exiting edit mode when not in edit mode."""
@ -341,7 +340,7 @@ class TestHostsManager:
manager.edit_mode = True
# Mock permission manager to raise exception
manager.permission_manager.release_sudo = Mock(
manager.permission_manager.clear_permission_state = Mock(
side_effect=Exception("Test error")
)