diff --git a/CHANGELOG.md b/CHANGELOG.md index c3e24e6..e53bdde 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/storage.rs b/src/storage.rs index af98ec4..c335892 100644 --- a/src/storage.rs +++ b/src/storage.rs @@ -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?; diff --git a/tests/storage/db.rs b/tests/storage/db.rs index 48a7d57..c5de6ce 100644 --- a/tests/storage/db.rs +++ b/tests/storage/db.rs @@ -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"); +} diff --git a/tests/storage/labels.rs b/tests/storage/labels.rs deleted file mode 100644 index a694b81..0000000 --- a/tests/storage/labels.rs +++ /dev/null @@ -1,8 +0,0 @@ -use terminalist::storage::LocalStorage; - -#[tokio::test] -async fn test_labels_storage_creation() { - // Test that we can create local storage for labels - let result = LocalStorage::new(false).await; - assert!(result.is_ok(), "LocalStorage should be created successfully"); -} diff --git a/tests/storage/projects.rs b/tests/storage/projects.rs deleted file mode 100644 index ef94a1a..0000000 --- a/tests/storage/projects.rs +++ /dev/null @@ -1,8 +0,0 @@ -use terminalist::storage::LocalStorage; - -#[tokio::test] -async fn test_projects_storage_creation() { - // Test that we can create local storage for projects - let result = LocalStorage::new(false).await; - assert!(result.is_ok(), "LocalStorage should be created successfully"); -} diff --git a/tests/storage/sections.rs b/tests/storage/sections.rs deleted file mode 100644 index f9f7697..0000000 --- a/tests/storage/sections.rs +++ /dev/null @@ -1,8 +0,0 @@ -use terminalist::storage::LocalStorage; - -#[tokio::test] -async fn test_sections_storage_creation() { - // Test that we can create local storage for sections - let result = LocalStorage::new(false).await; - assert!(result.is_ok(), "LocalStorage should be created successfully"); -} diff --git a/tests/storage/tasks.rs b/tests/storage/tasks.rs deleted file mode 100644 index 25a8d35..0000000 --- a/tests/storage/tasks.rs +++ /dev/null @@ -1,8 +0,0 @@ -use terminalist::storage::LocalStorage; - -#[tokio::test] -async fn test_tasks_storage_creation() { - // Test that we can create local storage for tasks - let result = LocalStorage::new(false).await; - assert!(result.is_ok(), "LocalStorage should be created successfully"); -}