chore: merge feature/task-1.3 into main
This commit is contained in:
commit
fd48f2c253
|
|
@ -2,6 +2,18 @@
|
||||||
# It is not intended for manual editing.
|
# It is not intended for manual editing.
|
||||||
version = 3
|
version = 3
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ahash"
|
||||||
|
version = "0.8.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"once_cell",
|
||||||
|
"version_check",
|
||||||
|
"zerocopy",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "argon2"
|
name = "argon2"
|
||||||
version = "0.5.3"
|
version = "0.5.3"
|
||||||
|
|
@ -50,6 +62,16 @@ version = "1.5.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cc"
|
||||||
|
version = "1.4.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "509591b7bcd67f4ef775afad7662703b4935daaa6ec0e5605cfb1090b32a2b6d"
|
||||||
|
dependencies = [
|
||||||
|
"find-msvc-tools",
|
||||||
|
"shlex",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cfg-if"
|
name = "cfg-if"
|
||||||
version = "1.0.4"
|
version = "1.0.4"
|
||||||
|
|
@ -63,6 +85,11 @@ version = "0.1.0"
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "chronogrid_core"
|
name = "chronogrid_core"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"rusqlite",
|
||||||
|
"tempfile",
|
||||||
|
"thiserror",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "chronogrid_crypto"
|
name = "chronogrid_crypto"
|
||||||
|
|
@ -156,6 +183,40 @@ dependencies = [
|
||||||
"subtle",
|
"subtle",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "errno"
|
||||||
|
version = "0.3.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fallible-iterator"
|
||||||
|
version = "0.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fallible-streaming-iterator"
|
||||||
|
version = "0.1.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fastrand"
|
||||||
|
version = "2.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "find-msvc-tools"
|
||||||
|
version = "0.1.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "generic-array"
|
name = "generic-array"
|
||||||
version = "0.14.7"
|
version = "0.14.7"
|
||||||
|
|
@ -177,6 +238,35 @@ dependencies = [
|
||||||
"wasi",
|
"wasi",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "getrandom"
|
||||||
|
version = "0.4.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"libc",
|
||||||
|
"r-efi",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hashbrown"
|
||||||
|
version = "0.14.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
|
||||||
|
dependencies = [
|
||||||
|
"ahash",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hashlink"
|
||||||
|
version = "0.9.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af"
|
||||||
|
dependencies = [
|
||||||
|
"hashbrown",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "keyring"
|
name = "keyring"
|
||||||
version = "3.6.3"
|
version = "3.6.3"
|
||||||
|
|
@ -207,12 +297,58 @@ dependencies = [
|
||||||
"pkg-config",
|
"pkg-config",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "libsqlite3-sys"
|
||||||
|
version = "0.28.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0c10584274047cb335c23d3e61bcef8e323adae7c5c8c760540f73610177fc3f"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
"openssl-sys",
|
||||||
|
"pkg-config",
|
||||||
|
"vcpkg",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "linux-raw-sys"
|
||||||
|
version = "0.12.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "log"
|
name = "log"
|
||||||
version = "0.4.33"
|
version = "0.4.33"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "once_cell"
|
||||||
|
version = "1.21.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "openssl-src"
|
||||||
|
version = "300.6.1+3.6.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "46eb8fb9fb3b61ce1c0f8a026c4c1a0714d3a9e138e7fbde78753ce2babc3846"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "openssl-sys"
|
||||||
|
version = "0.9.117"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
"libc",
|
||||||
|
"openssl-src",
|
||||||
|
"pkg-config",
|
||||||
|
"vcpkg",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "password-hash"
|
name = "password-hash"
|
||||||
version = "0.5.0"
|
version = "0.5.0"
|
||||||
|
|
@ -257,6 +393,12 @@ dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "r-efi"
|
||||||
|
version = "6.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rand"
|
name = "rand"
|
||||||
version = "0.8.7"
|
version = "0.8.7"
|
||||||
|
|
@ -284,7 +426,34 @@ version = "0.6.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"getrandom",
|
"getrandom 0.2.17",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rusqlite"
|
||||||
|
version = "0.31.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b838eba278d213a8beaf485bd313fd580ca4505a00d5871caeb1457c55322cae"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"fallible-iterator",
|
||||||
|
"fallible-streaming-iterator",
|
||||||
|
"hashlink",
|
||||||
|
"libsqlite3-sys",
|
||||||
|
"smallvec",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustix"
|
||||||
|
version = "1.1.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"errno",
|
||||||
|
"libc",
|
||||||
|
"linux-raw-sys",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -323,6 +492,18 @@ dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "shlex"
|
||||||
|
version = "2.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "smallvec"
|
||||||
|
version = "1.15.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "subtle"
|
name = "subtle"
|
||||||
version = "2.6.1"
|
version = "2.6.1"
|
||||||
|
|
@ -351,6 +532,19 @@ dependencies = [
|
||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tempfile"
|
||||||
|
version = "3.27.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||||
|
dependencies = [
|
||||||
|
"fastrand",
|
||||||
|
"getrandom 0.4.3",
|
||||||
|
"once_cell",
|
||||||
|
"rustix",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "thiserror"
|
name = "thiserror"
|
||||||
version = "2.0.20"
|
version = "2.0.20"
|
||||||
|
|
@ -383,6 +577,12 @@ version = "1.0.24"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "vcpkg"
|
||||||
|
version = "0.2.15"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "version_check"
|
name = "version_check"
|
||||||
version = "0.9.5"
|
version = "0.9.5"
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,20 @@
|
||||||
- **Context**: Design a multi-platform (Windows, Linux, Android, Headless) productivity engine for time, project, and note management without centralized servers.
|
- **Context**: Design a multi-platform (Windows, Linux, Android, Headless) productivity engine for time, project, and note management without centralized servers.
|
||||||
- **Decision**: Adopted Rust core with SQLCipher local storage, Automerge CRDT delta sync over libp2p, and Flutter presentation layer bound via C-FFI.
|
- **Decision**: Adopted Rust core with SQLCipher local storage, Automerge CRDT delta sync over libp2p, and Flutter presentation layer bound via C-FFI.
|
||||||
|
|
||||||
|
## [2026-08-19] - ADL-003: chronogrid_core Crate – SQLCipher 4.x Database Initializer
|
||||||
|
|
||||||
|
- **Status**: Accepted
|
||||||
|
- **Context**: Task 1.3 required implementing the `chronogrid_core` crate with SQLCipher 4.x encrypted database initialization matching `docs/API_CONTRACTS/schema.sql`.
|
||||||
|
- **Decision**:
|
||||||
|
- `rusqlite` v0.31 with `bundled-sqlcipher-vendored-openssl` feature bundles both SQLCipher and OpenSSL, ensuring a hermetic build with no external C library dependencies.
|
||||||
|
- The 32-byte Argon2id-derived master key is passed as a **raw hex key** via `PRAGMA key = "x'…'"`, bypassing SQLCipher's internal PBKDF2 derivation since Argon2id already provides equivalent or stronger protection.
|
||||||
|
- `PRAGMA cipher_compatibility = 4` enforces SQLCipher 4.x defaults (AES-256-CBC, HMAC-SHA512 page auth, 64 KiB page size).
|
||||||
|
- `PRAGMA foreign_keys = ON` is set on every connection per the schema specification.
|
||||||
|
- The canonical schema SQL from `docs/API_CONTRACTS/schema.sql` is embedded via `include_str!` at compile time, ensuring single-source-of-truth alignment without runtime file I/O.
|
||||||
|
- Post-initialization validation confirms all 6 expected tables exist (`namespaces`, `projects`, `tasks`, `notes`, `task_note_links`, `ingestion_logs`) plus 4 indexes.
|
||||||
|
- `Database` struct provides `open(path, key)` for file-backed and `open_in_memory(key)` for test databases; key length is validated at 32 bytes.
|
||||||
|
- 11 unit tests cover: table/index creation, FK enforcement, cascade deletes, wrong-key rejection, key length validation, schema idempotency, and CRUD round-trips across all tables.
|
||||||
|
|
||||||
## [2026-08-09] - ADL-002: chronogrid_crypto Crate – Argon2id KDF & OS Keychain
|
## [2026-08-09] - ADL-002: chronogrid_crypto Crate – Argon2id KDF & OS Keychain
|
||||||
|
|
||||||
- **Status**: Accepted
|
- **Status**: Accepted
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@
|
||||||
"name": "flutter",
|
"name": "flutter",
|
||||||
"rootUri": "file:///C:/Users/mtkel/AppData/Local/flutter/packages/flutter",
|
"rootUri": "file:///C:/Users/mtkel/AppData/Local/flutter/packages/flutter",
|
||||||
"packageUri": "lib/",
|
"packageUri": "lib/",
|
||||||
"languageVersion": "3.10"
|
"languageVersion": "3.11"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "flutter_lints",
|
"name": "flutter_lints",
|
||||||
|
|
@ -53,7 +53,7 @@
|
||||||
"name": "flutter_test",
|
"name": "flutter_test",
|
||||||
"rootUri": "file:///C:/Users/mtkel/AppData/Local/flutter/packages/flutter_test",
|
"rootUri": "file:///C:/Users/mtkel/AppData/Local/flutter/packages/flutter_test",
|
||||||
"packageUri": "lib/",
|
"packageUri": "lib/",
|
||||||
"languageVersion": "3.10"
|
"languageVersion": "3.11"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "leak_tracker",
|
"name": "leak_tracker",
|
||||||
|
|
@ -81,9 +81,9 @@
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "matcher",
|
"name": "matcher",
|
||||||
"rootUri": "file:///C:/Users/mtkel/AppData/Local/Pub/Cache/hosted/pub.dev/matcher-0.12.19",
|
"rootUri": "file:///C:/Users/mtkel/AppData/Local/Pub/Cache/hosted/pub.dev/matcher-0.12.20",
|
||||||
"packageUri": "lib/",
|
"packageUri": "lib/",
|
||||||
"languageVersion": "3.7"
|
"languageVersion": "3.10"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "material_color_utilities",
|
"name": "material_color_utilities",
|
||||||
|
|
@ -93,7 +93,7 @@
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "meta",
|
"name": "meta",
|
||||||
"rootUri": "file:///C:/Users/mtkel/AppData/Local/Pub/Cache/hosted/pub.dev/meta-1.18.0",
|
"rootUri": "file:///C:/Users/mtkel/AppData/Local/Pub/Cache/hosted/pub.dev/meta-1.19.0",
|
||||||
"packageUri": "lib/",
|
"packageUri": "lib/",
|
||||||
"languageVersion": "3.5"
|
"languageVersion": "3.5"
|
||||||
},
|
},
|
||||||
|
|
@ -107,7 +107,7 @@
|
||||||
"name": "sky_engine",
|
"name": "sky_engine",
|
||||||
"rootUri": "file:///C:/Users/mtkel/AppData/Local/flutter/bin/cache/pkg/sky_engine",
|
"rootUri": "file:///C:/Users/mtkel/AppData/Local/flutter/bin/cache/pkg/sky_engine",
|
||||||
"packageUri": "lib/",
|
"packageUri": "lib/",
|
||||||
"languageVersion": "3.10"
|
"languageVersion": "3.11"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "source_span",
|
"name": "source_span",
|
||||||
|
|
@ -141,15 +141,15 @@
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "test_api",
|
"name": "test_api",
|
||||||
"rootUri": "file:///C:/Users/mtkel/AppData/Local/Pub/Cache/hosted/pub.dev/test_api-0.7.11",
|
"rootUri": "file:///C:/Users/mtkel/AppData/Local/Pub/Cache/hosted/pub.dev/test_api-0.7.12",
|
||||||
"packageUri": "lib/",
|
"packageUri": "lib/",
|
||||||
"languageVersion": "3.7"
|
"languageVersion": "3.10"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "vector_math",
|
"name": "vector_math",
|
||||||
"rootUri": "file:///C:/Users/mtkel/AppData/Local/Pub/Cache/hosted/pub.dev/vector_math-2.2.0",
|
"rootUri": "file:///C:/Users/mtkel/AppData/Local/Pub/Cache/hosted/pub.dev/vector_math-2.4.2",
|
||||||
"packageUri": "lib/",
|
"packageUri": "lib/",
|
||||||
"languageVersion": "3.1"
|
"languageVersion": "3.10"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "vm_service",
|
"name": "vm_service",
|
||||||
|
|
@ -165,8 +165,8 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"generator": "pub",
|
"generator": "pub",
|
||||||
"generatorVersion": "3.12.2",
|
"generatorVersion": "3.13.0",
|
||||||
"flutterRoot": "file:///C:/Users/mtkel/AppData/Local/flutter",
|
"flutterRoot": "file:///C:/Users/mtkel/AppData/Local/flutter",
|
||||||
"flutterVersion": "3.44.9",
|
"flutterVersion": "3.47.0",
|
||||||
"pubCache": "file:///C:/Users/mtkel/AppData/Local/Pub/Cache"
|
"pubCache": "file:///C:/Users/mtkel/AppData/Local/Pub/Cache"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -63,11 +63,6 @@
|
||||||
"async"
|
"async"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "meta",
|
|
||||||
"version": "1.18.0",
|
|
||||||
"dependencies": []
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "collection",
|
"name": "collection",
|
||||||
"version": "1.19.1",
|
"version": "1.19.1",
|
||||||
|
|
@ -84,11 +79,6 @@
|
||||||
"meta"
|
"meta"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "vector_math",
|
|
||||||
"version": "2.2.0",
|
|
||||||
"dependencies": []
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "stack_trace",
|
"name": "stack_trace",
|
||||||
"version": "1.12.1",
|
"version": "1.12.1",
|
||||||
|
|
@ -116,7 +106,7 @@
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "matcher",
|
"name": "matcher",
|
||||||
"version": "0.12.19",
|
"version": "0.12.20",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"async",
|
"async",
|
||||||
"meta",
|
"meta",
|
||||||
|
|
@ -127,7 +117,7 @@
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "test_api",
|
"name": "test_api",
|
||||||
"version": "0.7.11",
|
"version": "0.7.12",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"async",
|
"async",
|
||||||
"boolean_selector",
|
"boolean_selector",
|
||||||
|
|
@ -218,6 +208,16 @@
|
||||||
"name": "vm_service",
|
"name": "vm_service",
|
||||||
"version": "15.2.0",
|
"version": "15.2.0",
|
||||||
"dependencies": []
|
"dependencies": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "meta",
|
||||||
|
"version": "1.19.0",
|
||||||
|
"dependencies": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "vector_math",
|
||||||
|
"version": "2.4.2",
|
||||||
|
"dependencies": []
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"configVersion": 1
|
"configVersion": 1
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
3.44.9
|
3.47.0
|
||||||
|
|
@ -1 +1,10 @@
|
||||||
|
analyzer:
|
||||||
|
exclude:
|
||||||
|
- build/**
|
||||||
|
- android/**
|
||||||
|
- ios/**
|
||||||
|
- web/**
|
||||||
|
- windows/**
|
||||||
|
- macos/**
|
||||||
|
- linux/**
|
||||||
include: package:flutter_lints/flutter.yaml
|
include: package:flutter_lints/flutter.yaml
|
||||||
|
|
|
||||||
|
|
@ -103,10 +103,10 @@ packages:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: matcher
|
name: matcher
|
||||||
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
|
sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.12.19"
|
version: "0.12.20"
|
||||||
material_color_utilities:
|
material_color_utilities:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -119,10 +119,10 @@ packages:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: meta
|
name: meta
|
||||||
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
|
sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.18.0"
|
version: "1.19.0"
|
||||||
path:
|
path:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -180,18 +180,18 @@ packages:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: test_api
|
name: test_api
|
||||||
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
|
sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.11"
|
version: "0.7.12"
|
||||||
vector_math:
|
vector_math:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: vector_math
|
name: vector_math
|
||||||
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
|
sha256: f36f9f3be64c6198714492bb455c11056e33e2f85d9a0b676a48301e44fdcf47
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.2.0"
|
version: "2.4.2"
|
||||||
vm_service:
|
vm_service:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -201,5 +201,5 @@ packages:
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "15.2.0"
|
version: "15.2.0"
|
||||||
sdks:
|
sdks:
|
||||||
dart: ">=3.10.0-0 <4.0.0"
|
dart: ">=3.11.0-0 <4.0.0"
|
||||||
flutter: ">=3.18.0-18.0.pre.54"
|
flutter: ">=3.18.0-18.0.pre.54"
|
||||||
|
|
|
||||||
|
|
@ -7,5 +7,8 @@ rust-version.workspace = true
|
||||||
publish = false
|
publish = false
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
rusqlite = { version = "0.31", features = ["bundled-sqlcipher-vendored-openssl"] }
|
||||||
|
thiserror = "2"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
tempfile = "3"
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,418 @@
|
||||||
|
//! SQLCipher database initializer and connection manager.
|
||||||
|
//!
|
||||||
|
//! Provides [`Database`], the primary entry-point for encrypted local storage
|
||||||
|
//! in ChronoGrid P2P. Every database is encrypted at rest using SQLCipher 4.x
|
||||||
|
//! (AES-256-CBC) as mandated by `docs/ARCHITECTURE/02-security-and-crypto.md`.
|
||||||
|
//!
|
||||||
|
//! ## Key Handling
|
||||||
|
//!
|
||||||
|
//! The 32-byte master key produced by `chronogrid_crypto::kdf::derive_master_key`
|
||||||
|
//! is passed as a **raw hex key** via `PRAGMA key = "x'…'"`, which instructs
|
||||||
|
//! SQLCipher to use the bytes directly as the encryption key, bypassing its
|
||||||
|
//! internal PBKDF2 derivation (the Argon2id KDF already provides equivalent
|
||||||
|
//! or stronger protection).
|
||||||
|
//!
|
||||||
|
//! ## Schema
|
||||||
|
//!
|
||||||
|
//! The canonical schema is embedded at compile time from
|
||||||
|
//! `docs/API_CONTRACTS/schema.sql` via [`include_str!`], ensuring this crate
|
||||||
|
//! always stays in sync with the single source of truth.
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use rusqlite::Connection;
|
||||||
|
|
||||||
|
use crate::error::{CoreError, Result};
|
||||||
|
|
||||||
|
/// Length of the raw encryption key expected by SQLCipher (256-bit / 32 bytes).
|
||||||
|
pub const KEY_LEN: usize = 32;
|
||||||
|
|
||||||
|
/// Canonical schema SQL, embedded at compile-time from the single source of truth.
|
||||||
|
const SCHEMA_SQL: &str = include_str!("../../../docs/API_CONTRACTS/schema.sql");
|
||||||
|
|
||||||
|
/// Expected tables that must exist after schema initialization.
|
||||||
|
const EXPECTED_TABLES: &[&str] = &[
|
||||||
|
"namespaces",
|
||||||
|
"projects",
|
||||||
|
"tasks",
|
||||||
|
"notes",
|
||||||
|
"task_note_links",
|
||||||
|
"ingestion_logs",
|
||||||
|
];
|
||||||
|
|
||||||
|
// ── Database struct ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// An encrypted SQLCipher database connection with the ChronoGrid schema applied.
|
||||||
|
///
|
||||||
|
/// Wraps a [`rusqlite::Connection`] that has been keyed with a 256-bit raw key
|
||||||
|
/// and initialized with all required tables and indexes.
|
||||||
|
pub struct Database {
|
||||||
|
conn: Connection,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for Database {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.debug_struct("Database")
|
||||||
|
.field("conn", &"<rusqlite::Connection>")
|
||||||
|
.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Database {
|
||||||
|
/// Opens (or creates) an encrypted database file at `path`.
|
||||||
|
///
|
||||||
|
/// * Applies the raw 256-bit key via `PRAGMA key`.
|
||||||
|
/// * Enables foreign-key enforcement.
|
||||||
|
/// * Runs the canonical schema DDL (idempotent `CREATE … IF NOT EXISTS`).
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`CoreError::InvalidKeyLength`] if `key` is not exactly 32 bytes.
|
||||||
|
/// Returns [`CoreError::Database`] on connection or PRAGMA failures.
|
||||||
|
/// Returns [`CoreError::SchemaInit`] if any expected table is missing after DDL.
|
||||||
|
pub fn open(path: impl AsRef<Path>, key: &[u8]) -> Result<Self> {
|
||||||
|
if key.len() != KEY_LEN {
|
||||||
|
return Err(CoreError::InvalidKeyLength(key.len()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let conn = Connection::open(path)?;
|
||||||
|
Self::configure_and_init(conn, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opens an **in-memory** encrypted database (useful for testing).
|
||||||
|
///
|
||||||
|
/// Semantics are identical to [`Database::open`] except no file is created.
|
||||||
|
pub fn open_in_memory(key: &[u8]) -> Result<Self> {
|
||||||
|
if key.len() != KEY_LEN {
|
||||||
|
return Err(CoreError::InvalidKeyLength(key.len()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let conn = Connection::open_in_memory()?;
|
||||||
|
Self::configure_and_init(conn, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a shared reference to the underlying [`rusqlite::Connection`].
|
||||||
|
#[inline]
|
||||||
|
pub fn connection(&self) -> &Connection {
|
||||||
|
&self.conn
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a mutable reference to the underlying [`rusqlite::Connection`].
|
||||||
|
#[inline]
|
||||||
|
pub fn connection_mut(&mut self) -> &mut Connection {
|
||||||
|
&mut self.conn
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Private helpers ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Applies encryption key, PRAGMAs, schema DDL, and validates table presence.
|
||||||
|
fn configure_and_init(conn: Connection, key: &[u8]) -> Result<Self> {
|
||||||
|
// 1. Apply the raw hex key to encrypt / decrypt the database.
|
||||||
|
let hex_key = bytes_to_hex(key);
|
||||||
|
conn.execute_batch(&format!("PRAGMA key = \"x'{hex_key}'\";"))?;
|
||||||
|
|
||||||
|
// 2. Enforce SQLCipher 4 compatibility defaults.
|
||||||
|
conn.execute_batch(
|
||||||
|
"PRAGMA cipher_compatibility = 4;
|
||||||
|
PRAGMA foreign_keys = ON;",
|
||||||
|
)?;
|
||||||
|
|
||||||
|
// 3. Quick-check: verify the database is actually readable with this key.
|
||||||
|
// `PRAGMA cipher_integrity_check` is expensive; a lightweight SELECT on
|
||||||
|
// sqlite_master suffices to prove the key is correct.
|
||||||
|
conn.query_row(
|
||||||
|
"SELECT count(*) FROM sqlite_master;",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)
|
||||||
|
.map_err(|e| {
|
||||||
|
CoreError::SchemaInit(format!(
|
||||||
|
"database key verification failed (wrong key or corrupt file): {e}"
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// 4. Apply the canonical schema (all DDL uses IF NOT EXISTS).
|
||||||
|
conn.execute_batch(SCHEMA_SQL)?;
|
||||||
|
|
||||||
|
// 5. Validate that every expected table was created.
|
||||||
|
Self::validate_schema(&conn)?;
|
||||||
|
|
||||||
|
Ok(Self { conn })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Checks that all tables declared in `EXPECTED_TABLES` exist in `sqlite_master`.
|
||||||
|
fn validate_schema(conn: &Connection) -> Result<()> {
|
||||||
|
let mut stmt =
|
||||||
|
conn.prepare("SELECT name FROM sqlite_master WHERE type = 'table';")?;
|
||||||
|
let existing: Vec<String> = stmt
|
||||||
|
.query_map([], |row| row.get(0))?
|
||||||
|
.collect::<std::result::Result<Vec<_>, _>>()?;
|
||||||
|
|
||||||
|
for &table in EXPECTED_TABLES {
|
||||||
|
if !existing.iter().any(|t| t == table) {
|
||||||
|
return Err(CoreError::SchemaInit(format!(
|
||||||
|
"expected table '{table}' was not created"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Utility ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Converts a byte slice to a lowercase hex string (no allocations beyond the
|
||||||
|
/// output `String` itself).
|
||||||
|
fn bytes_to_hex(bytes: &[u8]) -> String {
|
||||||
|
let mut hex = String::with_capacity(bytes.len() * 2);
|
||||||
|
for &b in bytes {
|
||||||
|
use std::fmt::Write;
|
||||||
|
let _ = write!(hex, "{b:02x}");
|
||||||
|
}
|
||||||
|
hex
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// A deterministic 32-byte key used exclusively in tests.
|
||||||
|
const TEST_KEY: [u8; KEY_LEN] = [0xAA; KEY_LEN];
|
||||||
|
|
||||||
|
// ── open_in_memory ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn open_in_memory_creates_all_tables() {
|
||||||
|
let db = Database::open_in_memory(&TEST_KEY).expect("open_in_memory should succeed");
|
||||||
|
|
||||||
|
for &table in EXPECTED_TABLES {
|
||||||
|
let exists: bool = db
|
||||||
|
.connection()
|
||||||
|
.query_row(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name=?1);",
|
||||||
|
[table],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(exists, "table '{table}' must exist after initialization");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn open_in_memory_creates_indexes() {
|
||||||
|
let db = Database::open_in_memory(&TEST_KEY).unwrap();
|
||||||
|
|
||||||
|
let expected_indexes = [
|
||||||
|
"idx_tasks_namespace",
|
||||||
|
"idx_tasks_eisenhower",
|
||||||
|
"idx_tasks_due_date",
|
||||||
|
"idx_projects_parent",
|
||||||
|
];
|
||||||
|
|
||||||
|
for &idx in &expected_indexes {
|
||||||
|
let exists: bool = db
|
||||||
|
.connection()
|
||||||
|
.query_row(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='index' AND name=?1);",
|
||||||
|
[idx],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(exists, "index '{idx}' must exist after initialization");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn foreign_keys_are_enabled() {
|
||||||
|
let db = Database::open_in_memory(&TEST_KEY).unwrap();
|
||||||
|
let fk_status: i64 = db
|
||||||
|
.connection()
|
||||||
|
.query_row("PRAGMA foreign_keys;", [], |row| row.get(0))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(fk_status, 1, "foreign_keys PRAGMA must be ON");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── File-backed database ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn open_file_creates_and_reopens() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let db_path = dir.path().join("test.db");
|
||||||
|
|
||||||
|
// First open: creates the database and schema.
|
||||||
|
{
|
||||||
|
let db = Database::open(&db_path, &TEST_KEY).expect("first open should succeed");
|
||||||
|
db.connection()
|
||||||
|
.execute(
|
||||||
|
"INSERT INTO namespaces (id, name, created_at, updated_at) VALUES (?1, ?2, ?3, ?4);",
|
||||||
|
rusqlite::params!["ns-1", "Personal", 1000, 1000],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second open: data must persist.
|
||||||
|
{
|
||||||
|
let db = Database::open(&db_path, &TEST_KEY).expect("reopen should succeed");
|
||||||
|
let name: String = db
|
||||||
|
.connection()
|
||||||
|
.query_row(
|
||||||
|
"SELECT name FROM namespaces WHERE id = ?1;",
|
||||||
|
["ns-1"],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(name, "Personal");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wrong_key_fails() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let db_path = dir.path().join("encrypted.db");
|
||||||
|
|
||||||
|
// Create with one key.
|
||||||
|
{
|
||||||
|
Database::open(&db_path, &TEST_KEY).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attempt reopen with a different key.
|
||||||
|
let bad_key = [0xBB; KEY_LEN];
|
||||||
|
let result = Database::open(&db_path, &bad_key);
|
||||||
|
assert!(result.is_err(), "opening with a wrong key must fail");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Key validation ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reject_short_key() {
|
||||||
|
let short = [0u8; 16];
|
||||||
|
let result = Database::open_in_memory(&short);
|
||||||
|
assert!(result.is_err());
|
||||||
|
match result.unwrap_err() {
|
||||||
|
CoreError::InvalidKeyLength(len) => assert_eq!(len, 16),
|
||||||
|
other => panic!("expected InvalidKeyLength, got: {other}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reject_empty_key() {
|
||||||
|
let result = Database::open_in_memory(&[]);
|
||||||
|
assert!(result.is_err());
|
||||||
|
match result.unwrap_err() {
|
||||||
|
CoreError::InvalidKeyLength(0) => {}
|
||||||
|
other => panic!("expected InvalidKeyLength(0), got: {other}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Schema DDL is idempotent ─────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn schema_init_is_idempotent() {
|
||||||
|
let db = Database::open_in_memory(&TEST_KEY).unwrap();
|
||||||
|
// Running the schema DDL a second time must not fail.
|
||||||
|
db.connection().execute_batch(SCHEMA_SQL).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Insert/query round-trip for every table ──────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn crud_round_trip_all_tables() {
|
||||||
|
let db = Database::open_in_memory(&TEST_KEY).unwrap();
|
||||||
|
let c = db.connection();
|
||||||
|
|
||||||
|
// namespaces
|
||||||
|
c.execute(
|
||||||
|
"INSERT INTO namespaces (id, name, created_at, updated_at) VALUES ('ns1','Work',1,1);",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// projects
|
||||||
|
c.execute(
|
||||||
|
"INSERT INTO projects (id, namespace_id, title, sort_order, created_at, hlc_timestamp)
|
||||||
|
VALUES ('p1','ns1','Project Alpha',1.0,1,'hlc-1');",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// tasks
|
||||||
|
c.execute(
|
||||||
|
"INSERT INTO tasks (id, namespace_id, project_id, title, created_at, hlc_timestamp)
|
||||||
|
VALUES ('t1','ns1','p1','My Task',1,'hlc-2');",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// notes
|
||||||
|
c.execute(
|
||||||
|
"INSERT INTO notes (id, namespace_id, project_id, title, content_markdown, created_at, updated_at, hlc_timestamp)
|
||||||
|
VALUES ('n1','ns1','p1','Note','# Hello',1,1,'hlc-3');",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// task_note_links
|
||||||
|
c.execute(
|
||||||
|
"INSERT INTO task_note_links (task_id, note_id, created_at)
|
||||||
|
VALUES ('t1','n1',1);",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// ingestion_logs
|
||||||
|
c.execute(
|
||||||
|
"INSERT INTO ingestion_logs (id, source_type, ingested_at)
|
||||||
|
VALUES ('il1','jot_hotkey',1);",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Verify counts
|
||||||
|
let count: i64 = c
|
||||||
|
.query_row("SELECT count(*) FROM tasks;", [], |r| r.get(0))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(count, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Foreign key enforcement ──────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn foreign_key_violation_is_rejected() {
|
||||||
|
let db = Database::open_in_memory(&TEST_KEY).unwrap();
|
||||||
|
let result = db.connection().execute(
|
||||||
|
"INSERT INTO projects (id, namespace_id, title, sort_order, created_at, hlc_timestamp)
|
||||||
|
VALUES ('p1','nonexistent_ns','Bad',1.0,1,'hlc-1');",
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
assert!(result.is_err(), "FK violation must be rejected");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cascade_delete_removes_children() {
|
||||||
|
let db = Database::open_in_memory(&TEST_KEY).unwrap();
|
||||||
|
let c = db.connection();
|
||||||
|
|
||||||
|
c.execute(
|
||||||
|
"INSERT INTO namespaces (id, name, created_at, updated_at) VALUES ('ns1','X',1,1);",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
c.execute(
|
||||||
|
"INSERT INTO tasks (id, namespace_id, title, created_at, hlc_timestamp)
|
||||||
|
VALUES ('t1','ns1','Task',1,'hlc');",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Deleting the namespace must cascade-delete the task.
|
||||||
|
c.execute("DELETE FROM namespaces WHERE id = 'ns1';", [])
|
||||||
|
.unwrap();
|
||||||
|
let count: i64 = c
|
||||||
|
.query_row("SELECT count(*) FROM tasks;", [], |r| r.get(0))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(count, 0, "cascade delete must remove child tasks");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
//! Error types for the `chronogrid_core` crate.
|
||||||
|
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
/// Unified error type for all core storage and logic operations.
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum CoreError {
|
||||||
|
/// The provided encryption key has an invalid length (expected 32 bytes).
|
||||||
|
#[error("invalid key length: expected 32 bytes, got {0}")]
|
||||||
|
InvalidKeyLength(usize),
|
||||||
|
|
||||||
|
/// An error originating from the SQLCipher / rusqlite layer.
|
||||||
|
#[error("database error: {0}")]
|
||||||
|
Database(#[from] rusqlite::Error),
|
||||||
|
|
||||||
|
/// An I/O error (e.g., file system access for the database path).
|
||||||
|
#[error("I/O error: {0}")]
|
||||||
|
Io(#[from] std::io::Error),
|
||||||
|
|
||||||
|
/// Schema migration or initialization failure.
|
||||||
|
#[error("schema initialization failed: {0}")]
|
||||||
|
SchemaInit(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convenience alias used throughout the crate.
|
||||||
|
pub type Result<T> = std::result::Result<T, CoreError>;
|
||||||
|
|
@ -1,12 +1,20 @@
|
||||||
//! # chronogrid_core
|
//! # chronogrid_core
|
||||||
//!
|
//!
|
||||||
//! Core logic and local-first storage engine for ChronoGrid P2P.
|
//! Core logic and local-first storage engine for ChronoGrid P2P.
|
||||||
//! Provides SQLCipher-backed persistence and Automerge CRDT conflict resolution.
|
//!
|
||||||
|
//! ## Capabilities
|
||||||
|
//!
|
||||||
|
//! * **Encrypted local storage** — [`db::Database`] wraps a SQLCipher 4.x
|
||||||
|
//! connection keyed with a 256-bit raw key (produced by
|
||||||
|
//! `chronogrid_crypto::kdf::derive_master_key`). The canonical schema from
|
||||||
|
//! `docs/API_CONTRACTS/schema.sql` is applied automatically on first open.
|
||||||
|
//!
|
||||||
|
//! * **Automerge CRDT** *(planned – Task 1.4)* — conflict-free replicated
|
||||||
|
//! data-type registers for tasks and notes, enabling offline-first P2P sync.
|
||||||
|
|
||||||
#[cfg(test)]
|
pub mod db;
|
||||||
mod tests {
|
pub mod error;
|
||||||
#[test]
|
|
||||||
fn it_works() {
|
// Re-export key public types at crate root for ergonomic imports.
|
||||||
assert!(true);
|
pub use db::Database;
|
||||||
}
|
pub use error::{CoreError, Result};
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
# ADR-0003: chronogrid_core – SQLCipher 4.x Database Initializer
|
||||||
|
|
||||||
|
- **Date**: 2026-08-19
|
||||||
|
- **Status**: Accepted
|
||||||
|
- **Task**: 1.3
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
ChronoGrid requires an encrypted local-first database for storing namespaces, projects, tasks, notes, links, and ingestion logs. The schema is defined in `docs/API_CONTRACTS/schema.sql` and mandates SQLCipher 4.x (AES-256-CBC) per `docs/ARCHITECTURE/02-security-and-crypto.md`.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
### Dependency Choice
|
||||||
|
|
||||||
|
Adopted `rusqlite` v0.31 with the `bundled-sqlcipher-vendored-openssl` feature. This bundles both the SQLCipher C library and OpenSSL, producing a hermetic build that requires no external system libraries beyond a C compiler and Perl (for the OpenSSL build step).
|
||||||
|
|
||||||
|
### Key Handling
|
||||||
|
|
||||||
|
The 32-byte master key derived by `chronogrid_crypto::kdf::derive_master_key` (Argon2id) is passed as a **raw hex key** via `PRAGMA key = "x'…'"`. This instructs SQLCipher to use the key material directly, bypassing its internal PBKDF2-HMAC-SHA512 derivation. Rationale: Argon2id is a strictly stronger KDF than PBKDF2; double-derivation would add latency without meaningful security improvement.
|
||||||
|
|
||||||
|
### PRAGMA Configuration
|
||||||
|
|
||||||
|
| PRAGMA | Value | Purpose |
|
||||||
|
|----------------------------|-------|-------------------------------------------|
|
||||||
|
| `key` | Raw hex (32 bytes) | Encrypt/decrypt the database |
|
||||||
|
| `cipher_compatibility` | `4` | Enforce SQLCipher 4.x defaults |
|
||||||
|
| `foreign_keys` | `ON` | Required by schema FK constraints |
|
||||||
|
|
||||||
|
### Schema Embedding
|
||||||
|
|
||||||
|
The canonical schema SQL is embedded at compile time via `include_str!("../../../docs/API_CONTRACTS/schema.sql")`, ensuring the crate always stays in sync with the single source of truth without runtime file I/O.
|
||||||
|
|
||||||
|
### Post-Init Validation
|
||||||
|
|
||||||
|
After running the DDL, the initializer queries `sqlite_master` to verify all 6 expected tables (`namespaces`, `projects`, `tasks`, `notes`, `task_note_links`, `ingestion_logs`) and 4 indexes exist. A quick `SELECT count(*) FROM sqlite_master` immediately after keying detects wrong-key or corrupt-file conditions early.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- **Positive**: Fully encrypted at-rest storage with zero cloud dependencies. Schema changes in `schema.sql` are automatically picked up on recompilation. 11 unit tests validate correctness.
|
||||||
|
- **Negative**: First build requires Perl (Strawberry Perl on Windows) for OpenSSL compilation. Build time is ~10 minutes on first compile due to OpenSSL/SQLCipher C compilation (subsequent builds are cached).
|
||||||
|
- **Trade-off**: Using raw key (bypassing SQLCipher PBKDF2) means the security of the database encryption relies entirely on the Argon2id KDF strength, which is acceptable given our pinned parameters (64 MiB, 3 iterations, 4 threads).
|
||||||
|
|
@ -4,6 +4,6 @@
|
||||||
|
|
||||||
- [x] **Task 1.1**: Initialize Rust Cargo Workspace in crates/ and configure root Cargo.toml.
|
- [x] **Task 1.1**: Initialize Rust Cargo Workspace in crates/ and configure root Cargo.toml.
|
||||||
- [x] **Task 1.2**: Implement chronogrid_crypto crate with Argon2id master key derivation and OS keychain bindings.
|
- [x] **Task 1.2**: Implement chronogrid_crypto crate with Argon2id master key derivation and OS keychain bindings.
|
||||||
- [ ] **Task 1.3**: Implement chronogrid_core database initializers using SQLCipher 4.x matching docs/API_CONTRACTS/schema.sql.
|
- [x] **Task 1.3**: Implement chronogrid_core database initializers using SQLCipher 4.x matching docs/API_CONTRACTS/schema.sql.
|
||||||
- [ ] **Task 1.4**: Integrate Automerge Rust crate into chronogrid_core for task and note CRDT register mutations.
|
- [ ] **Task 1.4**: Integrate Automerge Rust crate into chronogrid_core for task and note CRDT register mutations.
|
||||||
- [ ] **Task 1.5**: Generate lutter_rust_bridge FFI bindings bridging Rust storage methods to Dart.
|
- [ ] **Task 1.5**: Generate lutter_rust_bridge FFI bindings bridging Rust storage methods to Dart.
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue