Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Security
- **Cache file permissions** - The local SQLite cache stores the Todoist API token, and is now created with `0600` so only its owner can read it. Existing cache files are tightened on the next launch.

## [0.5.0] - 2026-03-25

### Added
Expand Down
10 changes: 10 additions & 0 deletions src/storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,16 @@ impl LocalStorage {

let conn = Database::connect(opt).await?;

// backends.credentials holds the API token in plaintext, so the cache is a credential
// file. Best-effort: a filesystem without Unix modes must not block startup.
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Err(e) = std::fs::set_permissions(&db_path, std::fs::Permissions::from_mode(0o600)) {
log::warn!("Failed to restrict permissions on {}: {e}", db_path.display());
}
}

let storage = LocalStorage { conn };
storage.discard_stale_schema().await?;
storage.init_schema().await?;
Expand Down
25 changes: 25 additions & 0 deletions tests/storage/db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -100,3 +100,28 @@ async fn test_stale_schema_version_rebuilds_cache() {
reopened.conn.close().await.expect("connection should close");
std::fs::remove_file(db_path).expect("test database should be removed");
}

#[cfg(unix)]
#[tokio::test]
async fn test_database_file_is_owner_only() {
use std::os::unix::fs::PermissionsExt;

let db_path = std::env::temp_dir().join(format!("terminalist-perms-{}.db", uuid::Uuid::new_v4()));

let storage = LocalStorage::new_at(db_path.clone())
.await
.expect("LocalStorage should be created successfully");
storage.conn.close().await.expect("connection should close");

let mode = std::fs::metadata(&db_path)
.expect("database file should exist")
.permissions()
.mode();
assert_eq!(
mode & 0o777,
0o600,
"the cache holds the API token, it must not be readable by others"
);

std::fs::remove_file(db_path).expect("test database should be removed");
}
8 changes: 0 additions & 8 deletions tests/storage/labels.rs

This file was deleted.

8 changes: 0 additions & 8 deletions tests/storage/projects.rs

This file was deleted.

8 changes: 0 additions & 8 deletions tests/storage/sections.rs

This file was deleted.

8 changes: 0 additions & 8 deletions tests/storage/tasks.rs

This file was deleted.