diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 0000000..c1fe26d --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,65 @@ +name: Docker + +on: + push: + branches: [main] + tags: ["v*"] + pull_request: + branches: [main] + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + build-and-push: + name: Build and Push Docker Image + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Container Registry + if: github.event_name != 'pull_request' + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata (tags, labels) + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=ref,event=branch + type=ref,event=pr + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{major}} + type=raw,value=latest,enable={{is_default_branch}} + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + platforms: linux/amd64,linux/arm64 + + - name: Verify image (PR only) + if: github.event_name == 'pull_request' + run: | + docker build -t zeroclaw-test . + docker run --rm zeroclaw-test --version diff --git a/docker-compose.yml b/docker-compose.yml index 6ecf02e..a923676 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,7 +9,7 @@ services: zeroclaw: - image: zeroclaw/zeroclaw:latest + image: ghcr.io/theonlyhennygod/zeroclaw:latest # Or build locally: # build: . container_name: zeroclaw diff --git a/src/channels/imessage.rs b/src/channels/imessage.rs index c3a8abf..7a0e76c 100644 --- a/src/channels/imessage.rs +++ b/src/channels/imessage.rs @@ -1,6 +1,8 @@ use crate::channels::traits::{Channel, ChannelMessage}; use async_trait::async_trait; use directories::UserDirs; +use rusqlite::{Connection, OpenFlags}; +use std::path::Path; use tokio::sync::mpsc; /// iMessage channel using macOS `AppleScript` bridge. @@ -199,60 +201,58 @@ end tell"# } } -/// Get the current max ROWID from the messages table -async fn get_max_rowid(db_path: &std::path::Path) -> anyhow::Result { - let output = tokio::process::Command::new("sqlite3") - .arg(db_path) - .arg("SELECT MAX(ROWID) FROM message WHERE is_from_me = 0;") - .output() - .await?; - - let stdout = String::from_utf8_lossy(&output.stdout); - let rowid = stdout.trim().parse::().unwrap_or(0); - Ok(rowid) +/// Get the current max ROWID from the messages table. +/// Uses rusqlite with parameterized queries for security (CWE-89 prevention). +async fn get_max_rowid(db_path: &Path) -> anyhow::Result { + let path = db_path.to_path_buf(); + let result = tokio::task::spawn_blocking(move || -> anyhow::Result { + let conn = Connection::open_with_flags( + &path, + OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX, + )?; + let mut stmt = conn.prepare( + "SELECT MAX(ROWID) FROM message WHERE is_from_me = 0" + )?; + let rowid: Option = stmt.query_row([], |row| row.get(0))?; + Ok(rowid.unwrap_or(0)) + }) + .await??; + Ok(result) } -/// Fetch messages newer than `since_rowid` +/// Fetch messages newer than `since_rowid`. +/// Uses rusqlite with parameterized queries for security (CWE-89 prevention). +/// The `since_rowid` parameter is bound safely, preventing SQL injection. async fn fetch_new_messages( - db_path: &std::path::Path, + db_path: &Path, since_rowid: i64, ) -> anyhow::Result> { - let query = format!( - "SELECT m.ROWID, h.id, m.text \ - FROM message m \ - JOIN handle h ON m.handle_id = h.ROWID \ - WHERE m.ROWID > {since_rowid} \ - AND m.is_from_me = 0 \ - AND m.text IS NOT NULL \ - ORDER BY m.ROWID ASC \ - LIMIT 20;" - ); - - let output = tokio::process::Command::new("sqlite3") - .arg("-separator") - .arg("|") - .arg(db_path) - .arg(&query) - .output() - .await?; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - anyhow::bail!("sqlite3 query failed: {stderr}"); - } - - let stdout = String::from_utf8_lossy(&output.stdout); - let mut results = Vec::new(); - - for line in stdout.lines() { - let parts: Vec<&str> = line.splitn(3, '|').collect(); - if parts.len() == 3 { - if let Ok(rowid) = parts[0].parse::() { - results.push((rowid, parts[1].to_string(), parts[2].to_string())); - } - } - } - + let path = db_path.to_path_buf(); + let results = tokio::task::spawn_blocking(move || -> anyhow::Result> { + let conn = Connection::open_with_flags( + &path, + OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX, + )?; + let mut stmt = conn.prepare( + "SELECT m.ROWID, h.id, m.text \ + FROM message m \ + JOIN handle h ON m.handle_id = h.ROWID \ + WHERE m.ROWID > ?1 \ + AND m.is_from_me = 0 \ + AND m.text IS NOT NULL \ + ORDER BY m.ROWID ASC \ + LIMIT 20" + )?; + let rows = stmt.query_map([since_rowid], |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + )) + })?; + rows.collect::, _>>().map_err(Into::into) + }) + .await??; Ok(results) } @@ -527,4 +527,332 @@ mod tests { assert!(is_valid_imessage_target(" +1234567890 ")); assert!(is_valid_imessage_target(" user@example.com ")); } + + // ══════════════════════════════════════════════════════════ + // SQLite/rusqlite Database Tests (CWE-89 Prevention) + // ══════════════════════════════════════════════════════════ + + /// Helper to create a temporary test database with Messages schema + fn create_test_db() -> (tempfile::TempDir, std::path::PathBuf) { + let dir = tempfile::tempdir().unwrap(); + let db_path = dir.path().join("chat.db"); + + let conn = Connection::open(&db_path).unwrap(); + + // Create minimal schema matching macOS Messages.app + conn.execute_batch( + "CREATE TABLE handle ( + ROWID INTEGER PRIMARY KEY, + id TEXT NOT NULL + ); + CREATE TABLE message ( + ROWID INTEGER PRIMARY KEY, + handle_id INTEGER, + text TEXT, + is_from_me INTEGER DEFAULT 0, + FOREIGN KEY (handle_id) REFERENCES handle(ROWID) + );" + ).unwrap(); + + (dir, db_path) + } + + #[tokio::test] + async fn get_max_rowid_empty_database() { + let (_dir, db_path) = create_test_db(); + let result = get_max_rowid(&db_path).await; + assert!(result.is_ok()); + // Empty table returns 0 (NULL coalesced) + assert_eq!(result.unwrap(), 0); + } + + #[tokio::test] + async fn get_max_rowid_with_messages() { + let (_dir, db_path) = create_test_db(); + + // Insert test data + { + let conn = Connection::open(&db_path).unwrap(); + conn.execute("INSERT INTO handle (ROWID, id) VALUES (1, '+1234567890')", []).unwrap(); + conn.execute( + "INSERT INTO message (ROWID, handle_id, text, is_from_me) VALUES (100, 1, 'Hello', 0)", + [] + ).unwrap(); + conn.execute( + "INSERT INTO message (ROWID, handle_id, text, is_from_me) VALUES (200, 1, 'World', 0)", + [] + ).unwrap(); + // This one is from_me=1, should be ignored + conn.execute( + "INSERT INTO message (ROWID, handle_id, text, is_from_me) VALUES (300, 1, 'Sent', 1)", + [] + ).unwrap(); + } + + let result = get_max_rowid(&db_path).await.unwrap(); + // Should return 200, not 300 (ignores is_from_me=1) + assert_eq!(result, 200); + } + + #[tokio::test] + async fn get_max_rowid_nonexistent_database() { + let path = std::path::Path::new("/nonexistent/path/chat.db"); + let result = get_max_rowid(path).await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn fetch_new_messages_empty_database() { + let (_dir, db_path) = create_test_db(); + let result = fetch_new_messages(&db_path, 0).await; + assert!(result.is_ok()); + assert!(result.unwrap().is_empty()); + } + + #[tokio::test] + async fn fetch_new_messages_returns_correct_data() { + let (_dir, db_path) = create_test_db(); + + // Insert test data + { + let conn = Connection::open(&db_path).unwrap(); + conn.execute("INSERT INTO handle (ROWID, id) VALUES (1, '+1234567890')", []).unwrap(); + conn.execute("INSERT INTO handle (ROWID, id) VALUES (2, 'user@example.com')", []).unwrap(); + conn.execute( + "INSERT INTO message (ROWID, handle_id, text, is_from_me) VALUES (10, 1, 'First message', 0)", + [] + ).unwrap(); + conn.execute( + "INSERT INTO message (ROWID, handle_id, text, is_from_me) VALUES (20, 2, 'Second message', 0)", + [] + ).unwrap(); + } + + let result = fetch_new_messages(&db_path, 0).await.unwrap(); + assert_eq!(result.len(), 2); + assert_eq!(result[0], (10, "+1234567890".to_string(), "First message".to_string())); + assert_eq!(result[1], (20, "user@example.com".to_string(), "Second message".to_string())); + } + + #[tokio::test] + async fn fetch_new_messages_filters_by_rowid() { + let (_dir, db_path) = create_test_db(); + + // Insert test data + { + let conn = Connection::open(&db_path).unwrap(); + conn.execute("INSERT INTO handle (ROWID, id) VALUES (1, '+1234567890')", []).unwrap(); + conn.execute( + "INSERT INTO message (ROWID, handle_id, text, is_from_me) VALUES (10, 1, 'Old message', 0)", + [] + ).unwrap(); + conn.execute( + "INSERT INTO message (ROWID, handle_id, text, is_from_me) VALUES (20, 1, 'New message', 0)", + [] + ).unwrap(); + } + + // Fetch only messages after ROWID 15 + let result = fetch_new_messages(&db_path, 15).await.unwrap(); + assert_eq!(result.len(), 1); + assert_eq!(result[0].0, 20); + assert_eq!(result[0].2, "New message"); + } + + #[tokio::test] + async fn fetch_new_messages_excludes_sent_messages() { + let (_dir, db_path) = create_test_db(); + + // Insert test data + { + let conn = Connection::open(&db_path).unwrap(); + conn.execute("INSERT INTO handle (ROWID, id) VALUES (1, '+1234567890')", []).unwrap(); + conn.execute( + "INSERT INTO message (ROWID, handle_id, text, is_from_me) VALUES (10, 1, 'Received', 0)", + [] + ).unwrap(); + conn.execute( + "INSERT INTO message (ROWID, handle_id, text, is_from_me) VALUES (20, 1, 'Sent by me', 1)", + [] + ).unwrap(); + } + + let result = fetch_new_messages(&db_path, 0).await.unwrap(); + assert_eq!(result.len(), 1); + assert_eq!(result[0].2, "Received"); + } + + #[tokio::test] + async fn fetch_new_messages_excludes_null_text() { + let (_dir, db_path) = create_test_db(); + + // Insert test data + { + let conn = Connection::open(&db_path).unwrap(); + conn.execute("INSERT INTO handle (ROWID, id) VALUES (1, '+1234567890')", []).unwrap(); + conn.execute( + "INSERT INTO message (ROWID, handle_id, text, is_from_me) VALUES (10, 1, 'Has text', 0)", + [] + ).unwrap(); + conn.execute( + "INSERT INTO message (ROWID, handle_id, text, is_from_me) VALUES (20, 1, NULL, 0)", + [] + ).unwrap(); + } + + let result = fetch_new_messages(&db_path, 0).await.unwrap(); + assert_eq!(result.len(), 1); + assert_eq!(result[0].2, "Has text"); + } + + #[tokio::test] + async fn fetch_new_messages_respects_limit() { + let (_dir, db_path) = create_test_db(); + + // Insert 25 messages (limit is 20) + { + let conn = Connection::open(&db_path).unwrap(); + conn.execute("INSERT INTO handle (ROWID, id) VALUES (1, '+1234567890')", []).unwrap(); + for i in 1..=25 { + conn.execute( + &format!("INSERT INTO message (ROWID, handle_id, text, is_from_me) VALUES ({i}, 1, 'Message {i}', 0)"), + [] + ).unwrap(); + } + } + + let result = fetch_new_messages(&db_path, 0).await.unwrap(); + assert_eq!(result.len(), 20); // Limited to 20 + assert_eq!(result[0].0, 1); // First message + assert_eq!(result[19].0, 20); // 20th message + } + + #[tokio::test] + async fn fetch_new_messages_ordered_by_rowid_asc() { + let (_dir, db_path) = create_test_db(); + + // Insert messages out of order + { + let conn = Connection::open(&db_path).unwrap(); + conn.execute("INSERT INTO handle (ROWID, id) VALUES (1, '+1234567890')", []).unwrap(); + conn.execute( + "INSERT INTO message (ROWID, handle_id, text, is_from_me) VALUES (30, 1, 'Third', 0)", + [] + ).unwrap(); + conn.execute( + "INSERT INTO message (ROWID, handle_id, text, is_from_me) VALUES (10, 1, 'First', 0)", + [] + ).unwrap(); + conn.execute( + "INSERT INTO message (ROWID, handle_id, text, is_from_me) VALUES (20, 1, 'Second', 0)", + [] + ).unwrap(); + } + + let result = fetch_new_messages(&db_path, 0).await.unwrap(); + assert_eq!(result.len(), 3); + assert_eq!(result[0].0, 10); + assert_eq!(result[1].0, 20); + assert_eq!(result[2].0, 30); + } + + #[tokio::test] + async fn fetch_new_messages_nonexistent_database() { + let path = std::path::Path::new("/nonexistent/path/chat.db"); + let result = fetch_new_messages(path, 0).await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn fetch_new_messages_handles_special_characters() { + let (_dir, db_path) = create_test_db(); + + // Insert message with special characters (potential SQL injection patterns) + { + let conn = Connection::open(&db_path).unwrap(); + conn.execute("INSERT INTO handle (ROWID, id) VALUES (1, '+1234567890')", []).unwrap(); + conn.execute( + "INSERT INTO message (ROWID, handle_id, text, is_from_me) VALUES (10, 1, 'Hello \"world'' OR 1=1; DROP TABLE message;--', 0)", + [] + ).unwrap(); + } + + let result = fetch_new_messages(&db_path, 0).await.unwrap(); + assert_eq!(result.len(), 1); + // The special characters should be preserved, not interpreted as SQL + assert!(result[0].2.contains("DROP TABLE")); + } + + #[tokio::test] + async fn fetch_new_messages_handles_unicode() { + let (_dir, db_path) = create_test_db(); + + { + let conn = Connection::open(&db_path).unwrap(); + conn.execute("INSERT INTO handle (ROWID, id) VALUES (1, '+1234567890')", []).unwrap(); + conn.execute( + "INSERT INTO message (ROWID, handle_id, text, is_from_me) VALUES (10, 1, 'Hello 🦀 世界 مرحبا', 0)", + [] + ).unwrap(); + } + + let result = fetch_new_messages(&db_path, 0).await.unwrap(); + assert_eq!(result.len(), 1); + assert_eq!(result[0].2, "Hello 🦀 世界 مرحبا"); + } + + #[tokio::test] + async fn fetch_new_messages_handles_empty_text() { + let (_dir, db_path) = create_test_db(); + + { + let conn = Connection::open(&db_path).unwrap(); + conn.execute("INSERT INTO handle (ROWID, id) VALUES (1, '+1234567890')", []).unwrap(); + conn.execute( + "INSERT INTO message (ROWID, handle_id, text, is_from_me) VALUES (10, 1, '', 0)", + [] + ).unwrap(); + } + + let result = fetch_new_messages(&db_path, 0).await.unwrap(); + // Empty string is NOT NULL, so it's included + assert_eq!(result.len(), 1); + assert_eq!(result[0].2, ""); + } + + #[tokio::test] + async fn fetch_new_messages_negative_rowid_edge_case() { + let (_dir, db_path) = create_test_db(); + + { + let conn = Connection::open(&db_path).unwrap(); + conn.execute("INSERT INTO handle (ROWID, id) VALUES (1, '+1234567890')", []).unwrap(); + conn.execute( + "INSERT INTO message (ROWID, handle_id, text, is_from_me) VALUES (10, 1, 'Test', 0)", + [] + ).unwrap(); + } + + // Negative rowid should still work (fetch all messages with ROWID > -1) + let result = fetch_new_messages(&db_path, -1).await.unwrap(); + assert_eq!(result.len(), 1); + } + + #[tokio::test] + async fn fetch_new_messages_large_rowid_edge_case() { + let (_dir, db_path) = create_test_db(); + + { + let conn = Connection::open(&db_path).unwrap(); + conn.execute("INSERT INTO handle (ROWID, id) VALUES (1, '+1234567890')", []).unwrap(); + conn.execute( + "INSERT INTO message (ROWID, handle_id, text, is_from_me) VALUES (10, 1, 'Test', 0)", + [] + ).unwrap(); + } + + // Very large rowid should return empty (no messages after this) + let result = fetch_new_messages(&db_path, i64::MAX - 1).await.unwrap(); + assert!(result.is_empty()); + } }