diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..e0036590 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,20 @@ +# Ensure consistent line endings across all platforms. +# This prevents Windows autocrlf from changing file contents and +# causing test failures or unexpected diffs. +* text=auto eol=lf + +# Explicitly mark binary files +*.png binary +*.ico binary +*.jpg binary +*.jpeg binary +*.gif binary +*.woff binary +*.woff2 binary +*.ttf binary +*.eot binary + +# Windows-specific files keep CRLF +*.bat text eol=crlf +*.cmd text eol=crlf +*.ps1 text eol=crlf diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 706b8cf0..0c90a7ac 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,6 +17,9 @@ jobs: compile: name: Compile runs-on: ubuntu-latest + env: + FASTEMBED_CACHE_DIR: /home/runner/.cache/fastembed + HF_HOME: /home/runner/.cache/fastembed steps: - uses: actions/checkout@v6 @@ -169,10 +172,23 @@ jobs: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} build: - name: Build (macOS) - runs-on: macos-latest + name: Build (${{ matrix.name }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 30 env: RUSTFLAGS: "-Dwarnings" + strategy: + fail-fast: false + matrix: + include: + - name: macOS + os: macos-latest + artifact: claudear-macos + binary_path: target/release/claudear + - name: Windows + os: windows-latest + artifact: claudear-windows + binary_path: target/release/claudear.exe steps: - uses: actions/checkout@v6 @@ -192,6 +208,7 @@ jobs: - name: Create dashboard dist stub run: mkdir -p dashboard/dist && touch dashboard/dist/index.html + shell: bash - name: Build release run: cargo build --release @@ -199,8 +216,55 @@ jobs: - name: Upload artifact uses: actions/upload-artifact@v7 with: - name: claudear-macos - path: target/release/claudear + name: ${{ matrix.artifact }} + path: ${{ matrix.binary_path }} + + test-windows: + name: Test (Windows) + runs-on: windows-latest + timeout-minutes: 30 + steps: + - name: Configure git + run: git config --global core.autocrlf false + + - uses: actions/checkout@v6 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Cache cargo registry + uses: actions/cache@v5 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo- + + - name: Cache fastembed model + uses: actions/cache@v5 + with: + path: ${{ runner.temp }}\.cache\fastembed + key: fastembed-nomic-v15-windows + + - name: Compile tests + run: cargo test --all-features --no-run + + - name: Warmup embedding model + timeout-minutes: 5 + run: cargo test --all-features -- feedback::embeddings::tests::test_warmup_downloads_model --exact --nocapture + env: + FASTEMBED_CACHE_DIR: ${{ runner.temp }}\.cache\fastembed + HF_HOME: ${{ runner.temp }}\.cache\fastembed + + - name: Run tests + timeout-minutes: 10 + run: cargo test --all-features + env: + FASTEMBED_CACHE_DIR: ${{ runner.temp }}\.cache\fastembed + HF_HOME: ${{ runner.temp }}\.cache\fastembed dashboard: name: Dashboard diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 51b3bf89..042ab501 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,7 +13,7 @@ env: jobs: build-release: - name: Build Release + name: Build Release (${{ matrix.asset_name }}) runs-on: ${{ matrix.os }} strategy: matrix: @@ -21,15 +21,22 @@ jobs: - os: ubuntu-latest target: x86_64-unknown-linux-gnu asset_name: claudear-linux-amd64 + binary_name: claudear - os: macos-latest target: aarch64-apple-darwin asset_name: claudear-macos-arm64 + binary_name: claudear + - os: windows-latest + target: x86_64-pc-windows-msvc + asset_name: claudear-windows-amd64 + binary_name: claudear.exe steps: - uses: actions/checkout@v6 - - name: Set version from tag - id: version + - name: Set version from tag (Unix) + if: runner.os != 'Windows' + id: version_unix env: RELEASE_TAG: ${{ github.event.release.tag_name }} run: | @@ -37,6 +44,27 @@ jobs: echo "version=${VERSION}" >> $GITHUB_OUTPUT sed -i.bak '/^\[package\]/,/^$/ s/^version = .*/version = "'"${VERSION}"'"/' Cargo.toml && rm -f Cargo.toml.bak + - name: Set version from tag (Windows) + if: runner.os == 'Windows' + id: version_windows + env: + RELEASE_TAG: ${{ github.event.release.tag_name }} + shell: pwsh + run: | + $VERSION = $env:RELEASE_TAG -replace '^v', '' + echo "version=$VERSION" >> $env:GITHUB_OUTPUT + (Get-Content Cargo.toml) -replace '^version = ".*"', "version = `"$VERSION`"" | Set-Content Cargo.toml + + - name: Resolve version + id: version + shell: bash + run: | + if [ -n "${{ steps.version_unix.outputs.version }}" ]; then + echo "version=${{ steps.version_unix.outputs.version }}" >> $GITHUB_OUTPUT + else + echo "version=${{ steps.version_windows.outputs.version }}" >> $GITHUB_OUTPUT + fi + - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable with: @@ -64,18 +92,34 @@ jobs: - name: Build release binary run: cargo build --release --target ${{ matrix.target }} - - name: Package binary + - name: Package binary (Unix) + if: runner.os != 'Windows' run: | mkdir -p release - cp target/${{ matrix.target }}/release/claudear release/${{ matrix.asset_name }} + cp target/${{ matrix.target }}/release/${{ matrix.binary_name }} release/${{ matrix.asset_name }} cd release tar -czvf ${{ matrix.asset_name }}.tar.gz ${{ matrix.asset_name }} - - name: Upload Release Asset + - name: Package binary (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + New-Item -ItemType Directory -Force -Path release + Copy-Item "target/${{ matrix.target }}/release/${{ matrix.binary_name }}" "release/${{ matrix.asset_name }}.exe" + Compress-Archive -Path "release/${{ matrix.asset_name }}.exe" -DestinationPath "release/${{ matrix.asset_name }}.zip" + + - name: Upload Release Asset (Unix) + if: runner.os != 'Windows' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: gh release upload "${{ github.ref_name }}" release/${{ matrix.asset_name }}.tar.gz --clobber + - name: Upload Release Asset (Windows) + if: runner.os == 'Windows' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: gh release upload "${{ github.ref_name }}" release/${{ matrix.asset_name }}.zip --clobber + publish-docker: name: Publish Docker Image runs-on: ubuntu-latest diff --git a/Cargo.lock b/Cargo.lock index 6ff7569a..d3f1649c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -310,28 +310,6 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" -[[package]] -name = "aws-lc-rs" -version = "1.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9a7b350e3bb1767102698302bc37256cbd48422809984b98d292c40e2579aa9" -dependencies = [ - "aws-lc-sys", - "zeroize", -] - -[[package]] -name = "aws-lc-sys" -version = "0.37.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b092fe214090261288111db7a2b2c2118e5a7f30dc2569f1732c4069a6840549" -dependencies = [ - "cc", - "cmake", - "dunce", - "fs_extra", -] - [[package]] name = "axum" version = "0.8.8" @@ -771,6 +749,7 @@ dependencies = [ "reqwest 0.13.2", "rusqlite", "rust-embed", + "rustls", "rustls-acme", "semver", "sentry", @@ -811,6 +790,7 @@ dependencies = [ "urlencoding", "uuid", "walkdir", + "windows-sys 0.59.0", "zeroize", ] @@ -1127,16 +1107,6 @@ dependencies = [ "version_check", ] -[[package]] -name = "core-foundation" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" -dependencies = [ - "core-foundation-sys", - "libc", -] - [[package]] name = "core-foundation" version = "0.10.1" @@ -1458,12 +1428,6 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1435fa1053d8b2fbbe9be7e97eca7f33d37b28409959813daefc1446a14247f1" -[[package]] -name = "dunce" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" - [[package]] name = "ecdsa" version = "0.16.9" @@ -1768,12 +1732,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "fs_extra" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" - [[package]] name = "futures" version = "0.3.32" @@ -2271,11 +2229,9 @@ dependencies = [ "percent-encoding", "pin-project-lite", "socket2", - "system-configuration", "tokio", "tower-service", "tracing", - "windows-registry", ] [[package]] @@ -3736,7 +3692,6 @@ version = "0.11.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" dependencies = [ - "aws-lc-rs", "bytes", "getrandom 0.3.4", "lru-slab", @@ -3906,8 +3861,8 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2" dependencies = [ - "aws-lc-rs", "pem", + "ring", "rustls-pki-types", "time", "yasna", @@ -4033,7 +3988,6 @@ dependencies = [ "mime", "percent-encoding", "pin-project-lite", - "quinn", "rustls", "rustls-pki-types", "rustls-platform-verifier", @@ -4206,7 +4160,6 @@ version = "0.23.36" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" dependencies = [ - "aws-lc-rs", "log", "once_cell", "ring", @@ -4225,7 +4178,6 @@ dependencies = [ "async-io", "async-trait", "async-web-client", - "aws-lc-rs", "axum-server", "base64 0.22.1", "blocking", @@ -4236,6 +4188,7 @@ dependencies = [ "log", "pem", "rcgen", + "ring", "serde", "serde_json", "thiserror 2.0.18", @@ -4274,7 +4227,7 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" dependencies = [ - "core-foundation 0.10.1", + "core-foundation", "core-foundation-sys", "jni", "log", @@ -4301,7 +4254,6 @@ version = "0.103.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" dependencies = [ - "aws-lc-rs", "ring", "rustls-pki-types", "untrusted", @@ -4375,7 +4327,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ "bitflags 2.11.0", - "core-foundation 0.10.1", + "core-foundation", "core-foundation-sys", "libc", "security-framework-sys", @@ -4845,27 +4797,6 @@ dependencies = [ "windows", ] -[[package]] -name = "system-configuration" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" -dependencies = [ - "bitflags 2.11.0", - "core-foundation 0.9.4", - "system-configuration-sys", -] - -[[package]] -name = "system-configuration-sys" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" -dependencies = [ - "core-foundation-sys", - "libc", -] - [[package]] name = "tempfile" version = "3.26.0" @@ -6009,17 +5940,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-registry" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" -dependencies = [ - "windows-link", - "windows-result", - "windows-strings", -] - [[package]] name = "windows-result" version = "0.4.1" diff --git a/Cargo.toml b/Cargo.toml index 1ac77819..13504dab 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,12 +9,15 @@ tokio = { version = "1", features = ["rt-multi-thread", "time", "sync", "process # HTTP server & client axum = { version = "0.8", features = ["macros", "ws", "multipart"] } axum-server = { version = "0.8", features = ["tls-rustls-no-provider"] } -reqwest = { version = "0.13", features = ["json", "form", "stream"] } +reqwest = { version = "0.13", default-features = false, features = ["json", "form", "stream", "rustls-no-provider", "charset", "http2"] } tower = { version = "0.5", features = ["limit"] } tower-http = { version = "0.6", features = ["trace", "cors", "fs", "limit"] } +# TLS (ring crypto provider for all rustls consumers) +rustls = { version = "0.23", default-features = false, features = ["ring", "logging", "std", "tls12"] } + # TLS auto-provisioning (Let's Encrypt ACME) -rustls-acme = { version = "0.15", features = ["axum", "aws-lc-rs"] } +rustls-acme = { version = "0.15", default-features = false, features = ["axum", "ring", "tls12", "webpki-roots"] } # Database rusqlite = { version = "0.38", features = ["bundled", "load_extension"] } @@ -187,6 +190,9 @@ reqwest = { workspace = true } tower = { workspace = true } tower-http = { workspace = true } +# TLS (ring crypto provider) +rustls = { workspace = true } + # TLS auto-provisioning (Let's Encrypt ACME) rustls-acme = { workspace = true } @@ -320,6 +326,11 @@ openssl = { version = "0.10", features = ["vendored"] } [target.'cfg(unix)'.dependencies] libc = "0.2" +# Process checking on Windows +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_System_Threading"] } + + [dev-dependencies] mockall = { workspace = true } tower = { version = "0.5", features = ["util"] } diff --git a/crates/claudear-analysis/src/evaluation/detector.rs b/crates/claudear-analysis/src/evaluation/detector.rs index 60660a22..0c159c17 100644 --- a/crates/claudear-analysis/src/evaluation/detector.rs +++ b/crates/claudear-analysis/src/evaluation/detector.rs @@ -598,13 +598,7 @@ pub fn detect_tools(project_dir: &Path, overrides: &ToolOverrides) -> Vec bool { - std::process::Command::new("which") - .arg(binary) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .status() - .map(|s| s.success()) - .unwrap_or(false) + claudear_core::platform::command_exists(binary) } fn shell_words(cmd: &str) -> Vec { @@ -878,8 +872,11 @@ mod tests { #[test] fn test_which_exists_for_known_binary() { - // "ls" should exist on all Unix systems + // Use a binary that exists on all platforms + #[cfg(not(windows))] assert!(which_exists("ls")); + #[cfg(windows)] + assert!(which_exists("cmd")); } #[test] diff --git a/crates/claudear-analysis/src/repo/git.rs b/crates/claudear-analysis/src/repo/git.rs index a5b45e98..4c7818a6 100644 --- a/crates/claudear-analysis/src/repo/git.rs +++ b/crates/claudear-analysis/src/repo/git.rs @@ -452,6 +452,20 @@ mod tests { use std::process::Command as StdCommand; use tempfile::TempDir; + /// Build a `file://` URL from a local path. + /// + /// On Windows, `Path::display()` emits backslashes (e.g. `C:\Users\...`), + /// but `file://` URLs require forward slashes (`file:///C:/Users/...`). + fn file_url(path: &Path) -> String { + let s = path.display().to_string().replace('\\', "/"); + if s.starts_with('/') { + format!("file://{s}") + } else { + // Windows absolute path like C:/Users/... needs an extra / + format!("file:///{s}") + } + } + /// Create a bare-minimum git repo in `path` with one commit on "main". fn init_git_repo(path: &Path) { StdCommand::new("git") @@ -1010,7 +1024,7 @@ mod tests { let target_dir = TempDir::new().unwrap(); let target = target_dir.path().join("cloned"); - let url = format!("file://{}", origin.path().display()); + let url = file_url(origin.path()); let result = GitOps::ensure_repo_at_path(&target, &url, "main").await; assert!(result.is_ok(), "clone failed: {:?}", result.unwrap_err()); assert!(target.join(".git").exists()); @@ -1025,7 +1039,7 @@ mod tests { let target_dir = TempDir::new().unwrap(); let target = target_dir.path().join("cloned"); - let url = format!("file://{}", origin.path().display()); + let url = file_url(origin.path()); GitOps::ensure_repo_at_path(&target, &url, "main") .await .unwrap(); @@ -1060,7 +1074,7 @@ mod tests { let target_dir = TempDir::new().unwrap(); let target = target_dir.path().join("cloned"); - let url = format!("file://{}", origin.path().display()); + let url = file_url(origin.path()); // Clone first GitOps::ensure_repo_at_path(&target, &url, "main") .await @@ -1112,7 +1126,7 @@ mod tests { let target_dir = TempDir::new().unwrap(); let target = target_dir.path().join("cloned"); - let url = format!("file://{}", origin.path().display()); + let url = file_url(origin.path()); GitOps::ensure_repo_at_path(&target, &url, "main") .await .unwrap(); @@ -1133,7 +1147,7 @@ mod tests { let target_dir = TempDir::new().unwrap(); let target = target_dir.path().join("cloned"); - let url = format!("file://{}", origin.path().display()); + let url = file_url(origin.path()); GitOps::ensure_repo_at_path(&target, &url, "main") .await .unwrap(); @@ -1159,7 +1173,7 @@ mod tests { let target_dir = TempDir::new().unwrap(); let target = target_dir.path().join("cloned"); - let url = format!("file://{}", origin.path().display()); + let url = file_url(origin.path()); GitOps::ensure_repo_at_path(&target, &url, "main") .await .unwrap(); @@ -1420,7 +1434,7 @@ mod tests { let target_dir = TempDir::new().unwrap(); let target = target_dir.path().join("cloned"); - let url = format!("file://{}", origin.path().display()); + let url = file_url(origin.path()); // Clone GitOps::ensure_repo_at_path(&target, &url, "main") @@ -1614,7 +1628,7 @@ mod tests { let target_dir = TempDir::new().unwrap(); let target = target_dir.path().join("cloned"); - let url = format!("file://{}", origin.path().display()); + let url = file_url(origin.path()); GitOps::ensure_repo_at_path(&target, &url, "main") .await @@ -1670,7 +1684,7 @@ mod tests { let target_dir = TempDir::new().unwrap(); let target = target_dir.path().join("cloned"); - let url = format!("file://{}", origin.path().display()); + let url = file_url(origin.path()); GitOps::ensure_repo_at_path(&target, &url, "main") .await @@ -1915,7 +1929,7 @@ mod tests { let workspace = TempDir::new().unwrap(); let repo_path = workspace.path().join("repo"); - let url = format!("file://{}", origin.path().display()); + let url = file_url(origin.path()); // 1) Clone GitOps::ensure_repo_at_path(&repo_path, &url, "main") @@ -2054,7 +2068,7 @@ mod tests { let target_dir = TempDir::new().unwrap(); let target = target_dir.path().join("cloned"); - let url = format!("file://{}", origin.path().display()); + let url = file_url(origin.path()); // Clone via ensure_repo_at_path GitOps::ensure_repo_at_path(&target, &url, "main") @@ -2154,7 +2168,7 @@ mod tests { // Clone let target_dir = TempDir::new().unwrap(); let target = target_dir.path().join("cloned"); - let url = format!("file://{}", origin.path().display()); + let url = file_url(origin.path()); GitOps::ensure_repo_at_path(&target, &url, "main") .await .unwrap(); diff --git a/crates/claudear-config/src/env_writer.rs b/crates/claudear-config/src/env_writer.rs index 40c04f51..1103f04b 100644 --- a/crates/claudear-config/src/env_writer.rs +++ b/crates/claudear-config/src/env_writer.rs @@ -26,13 +26,8 @@ pub fn update_env_file(path: &Path, updates: &HashMap) -> Result .map_err(|e| Error::config(format!("Failed to write .env file at {:?}: {}", path, e)))?; // Set restrictive permissions on the .env file (owner read/write only) - // since it may contain secrets like webhook secrets and API tokens - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let perms = std::fs::Permissions::from_mode(0o600); - fs::set_permissions(path, perms).ok(); - } + // since it may contain secrets like webhook secrets and API tokens. + claudear_core::platform::set_file_permissions_secure(path).ok(); Ok(()) } diff --git a/crates/claudear-core/Cargo.toml b/crates/claudear-core/Cargo.toml index 8988653d..313262ad 100644 --- a/crates/claudear-core/Cargo.toml +++ b/crates/claudear-core/Cargo.toml @@ -51,6 +51,14 @@ dirs = { workspace = true } # Database (optional, just for From) rusqlite = { workspace = true, optional = true } +# Process checking and IPC on Unix +[target.'cfg(unix)'.dependencies] +libc = "0.2" + +# Process checking on Windows +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_System_Threading"] } + [dev-dependencies] tempfile = { workspace = true } toml = { workspace = true } diff --git a/crates/claudear-core/src/lib.rs b/crates/claudear-core/src/lib.rs index 40bbba73..343c3f03 100644 --- a/crates/claudear-core/src/lib.rs +++ b/crates/claudear-core/src/lib.rs @@ -5,6 +5,7 @@ pub mod error; pub mod http; +pub mod platform; pub mod secret; pub mod templates; pub mod types; diff --git a/crates/claudear-core/src/platform.rs b/crates/claudear-core/src/platform.rs new file mode 100644 index 00000000..ef68a972 --- /dev/null +++ b/crates/claudear-core/src/platform.rs @@ -0,0 +1,151 @@ +//! Platform abstraction layer. +//! +//! Centralises all OS-specific logic so that the rest of the codebase can stay +//! platform-agnostic. Every function is a no-op or a sensible default on +//! platforms where the underlying primitive does not exist. + +use std::path::Path; + +// --------------------------------------------------------------------------- +// File permissions +// --------------------------------------------------------------------------- + +/// Set restrictive **file** permissions (Unix `0o600` — owner read/write only). +/// +/// On Windows this is a no-op; NTFS ACLs are inherited from the parent +/// directory and are typically sufficient for single-user machines. +pub fn set_file_permissions_secure(path: &Path) -> std::io::Result<()> { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)) + } + #[cfg(not(unix))] + { + let _ = path; + Ok(()) + } +} + +/// Set restrictive **directory** permissions (Unix `0o700` — owner only). +/// +/// On Windows this is a no-op. +pub fn set_dir_permissions_secure(path: &Path) -> std::io::Result<()> { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700)) + } + #[cfg(not(unix))] + { + let _ = path; + Ok(()) + } +} + +// --------------------------------------------------------------------------- +// Process detection +// --------------------------------------------------------------------------- + +/// Check whether a process with the given PID is still alive. +pub fn is_process_running(pid: u32) -> bool { + #[cfg(target_os = "linux")] + { + std::path::Path::new(&format!("/proc/{}", pid)).exists() + } + + #[cfg(target_os = "macos")] + { + // SAFETY: kill with signal 0 doesn't actually send a signal, + // it just checks if the process exists and we have permission to signal it. + match i32::try_from(pid) { + Ok(pid_i32) => unsafe { libc::kill(pid_i32, 0) == 0 }, + Err(_) => false, + } + } + + #[cfg(target_os = "windows")] + { + use windows_sys::Win32::Foundation::CloseHandle; + use windows_sys::Win32::System::Threading::{ + OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, + }; + // SAFETY: OpenProcess with PROCESS_QUERY_LIMITED_INFORMATION is a + // read-only check. We immediately close the handle afterwards. + unsafe { + let handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid); + if !handle.is_null() { + CloseHandle(handle); + true + } else { + false + } + } + } + + #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] + { + let _ = pid; + true // assume running when we cannot check + } +} + +// --------------------------------------------------------------------------- +// Command / binary detection +// --------------------------------------------------------------------------- + +/// Check whether a command exists on the system PATH. +/// +/// Uses `which` on Unix and `where` on Windows. +pub fn command_exists(binary: &str) -> bool { + #[cfg(not(windows))] + let cmd = "which"; + #[cfg(windows)] + let cmd = "where"; + + std::process::Command::new(cmd) + .arg(binary) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_is_process_running_own_pid() { + assert!(is_process_running(std::process::id())); + } + + #[test] + fn test_is_process_running_invalid_pid() { + assert!(!is_process_running(u32::MAX)); + } + + #[test] + fn test_command_exists_known_binary() { + #[cfg(not(windows))] + assert!(command_exists("ls")); + #[cfg(windows)] + assert!(command_exists("cmd")); + } + + #[test] + fn test_command_exists_nonexistent() { + assert!(!command_exists("__nonexistent_binary_12345__")); + } + + #[test] + fn test_set_file_permissions_secure_nonexistent_path() { + let result = set_file_permissions_secure(Path::new("/tmp/__does_not_exist_12345__")); + // On Unix this should fail; on Windows it's a no-op (Ok). + #[cfg(unix)] + assert!(result.is_err()); + #[cfg(not(unix))] + assert!(result.is_ok()); + } +} diff --git a/crates/claudear-core/src/secret/encryption.rs b/crates/claudear-core/src/secret/encryption.rs index f55eb2a2..9df6f0fa 100644 --- a/crates/claudear-core/src/secret/encryption.rs +++ b/crates/claudear-core/src/secret/encryption.rs @@ -196,13 +196,8 @@ pub fn write_key_file(path: &Path, key: &MasterKey) -> Result<(), String> { .map_err(|e| format!("Failed to write key file '{}': {}", path.display(), e))?; // Set restrictive permissions (owner read/write only) - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let perms = std::fs::Permissions::from_mode(0o600); - std::fs::set_permissions(path, perms) - .map_err(|e| format!("Failed to set key file permissions: {}", e))?; - } + crate::platform::set_file_permissions_secure(path) + .map_err(|e| format!("Failed to set key file permissions: {}", e))?; Ok(()) } diff --git a/crates/claudear-e2e/src/main.rs b/crates/claudear-e2e/src/main.rs index 44fdd5cc..4e433ef1 100644 --- a/crates/claudear-e2e/src/main.rs +++ b/crates/claudear-e2e/src/main.rs @@ -252,6 +252,8 @@ fn reviewer_token(scm: &str) -> Option { #[tokio::main] async fn main() -> Result<()> { + claudear::init_tls(); + tracing_subscriber::fmt() .with_env_filter( tracing_subscriber::EnvFilter::try_from_default_env() diff --git a/crates/claudear-engine/src/ipc/client.rs b/crates/claudear-engine/src/ipc/client.rs index 980dd1b9..86809342 100644 --- a/crates/claudear-engine/src/ipc/client.rs +++ b/crates/claudear-engine/src/ipc/client.rs @@ -1,19 +1,22 @@ //! IPC client for communicating with the watcher daemon. +//! +//! Transport details are handled by [`super::transport`] — this file is +//! platform-agnostic. use super::default_socket_path; use super::protocol::{IpcCommand, IpcData, IpcResponse}; +use super::transport; use claudear_core::error::{Error, Result}; use std::path::PathBuf; use std::time::Duration; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; -use tokio::net::UnixStream; use tokio::time::timeout; /// Default timeout for IPC operations. const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30); -/// Client for communicating with the watcher daemon via Unix socket. +/// Client for communicating with the watcher daemon. pub struct IpcClient { socket_path: PathBuf, timeout: Duration, @@ -28,7 +31,7 @@ impl IpcClient { } } - /// Create a client with a custom socket path. + /// Create a client with a custom socket/port file path. pub fn with_socket_path(socket_path: PathBuf) -> Self { Self { socket_path, @@ -44,8 +47,7 @@ impl IpcClient { /// Check if the daemon is running. pub fn is_daemon_running(&self) -> bool { - self.socket_path.exists() - && std::os::unix::net::UnixStream::connect(&self.socket_path).is_ok() + transport::check_connection(&self.socket_path) } /// Send a command and receive a response. @@ -59,7 +61,7 @@ impl IpcClient { } async fn send_internal(&self, command: IpcCommand) -> Result { - let stream = UnixStream::connect(&self.socket_path) + let stream = transport::connect(&self.socket_path) .await .map_err(|e| Error::Other(format!("Failed to connect to daemon: {}", e)))?; @@ -283,7 +285,7 @@ mod tests { #[test] fn test_client_with_socket_path() { - let path = PathBuf::from("/tmp/test.sock"); + let path = std::env::temp_dir().join("test-claudear.sock"); let client = IpcClient::with_socket_path(path.clone()); assert_eq!(client.socket_path, path); } @@ -299,7 +301,7 @@ mod tests { #[test] fn test_with_socket_path_uses_custom_path() { - let custom = PathBuf::from("/var/run/custom-claudear.sock"); + let custom = std::env::temp_dir().join("custom-claudear.sock"); let client = IpcClient::with_socket_path(custom.clone()); assert_eq!(client.socket_path, custom); } @@ -312,7 +314,7 @@ mod tests { #[test] fn test_with_socket_path_uses_default_timeout() { - let client = IpcClient::with_socket_path(PathBuf::from("/tmp/x.sock")); + let client = IpcClient::with_socket_path(std::env::temp_dir().join("x.sock")); assert_eq!(client.timeout, Duration::from_secs(30)); } @@ -326,7 +328,7 @@ mod tests { #[test] fn test_with_timeout_chained_with_socket_path() { - let path = PathBuf::from("/tmp/chained.sock"); + let path = std::env::temp_dir().join("chained.sock"); let client = IpcClient::with_socket_path(path.clone()).with_timeout(Duration::from_millis(500)); assert_eq!(client.socket_path, path); @@ -343,8 +345,9 @@ mod tests { #[test] fn test_is_daemon_running_nonexistent_socket() { - let client = - IpcClient::with_socket_path(PathBuf::from("/tmp/nonexistent-claudear-test.sock")); + let client = IpcClient::with_socket_path( + std::env::temp_dir().join("nonexistent-claudear-test.sock"), + ); assert!(!client.is_daemon_running()); } @@ -363,12 +366,13 @@ mod tests { #[tokio::test] async fn test_send_to_nonexistent_socket_returns_error() { let client = - IpcClient::with_socket_path(PathBuf::from("/tmp/claudear-no-such-socket.sock")); + IpcClient::with_socket_path(std::env::temp_dir().join("claudear-no-such-socket.sock")); let result = client.send(IpcCommand::Ping).await; assert!(result.is_err()); let err_msg = result.unwrap_err().to_string(); assert!( - err_msg.contains("Failed to connect to daemon"), + err_msg.contains("Failed to connect to daemon") + || err_msg.contains("Failed to read port file"), "Unexpected error message: {}", err_msg ); @@ -377,7 +381,7 @@ mod tests { #[tokio::test] async fn test_ping_nonexistent_socket_returns_false() { let client = - IpcClient::with_socket_path(PathBuf::from("/tmp/claudear-no-such-socket.sock")); + IpcClient::with_socket_path(std::env::temp_dir().join("claudear-no-such-socket.sock")); let result = client.ping().await; assert!(result.is_ok()); assert!(!result.unwrap()); @@ -386,7 +390,7 @@ mod tests { #[tokio::test] async fn test_status_nonexistent_socket_returns_error() { let client = - IpcClient::with_socket_path(PathBuf::from("/tmp/claudear-no-such-socket.sock")); + IpcClient::with_socket_path(std::env::temp_dir().join("claudear-no-such-socket.sock")); let result = client.status().await; assert!(result.is_err()); } @@ -394,7 +398,7 @@ mod tests { #[tokio::test] async fn test_send_trigger_nonexistent_socket_returns_error() { let client = - IpcClient::with_socket_path(PathBuf::from("/tmp/claudear-no-such-socket.sock")); + IpcClient::with_socket_path(std::env::temp_dir().join("claudear-no-such-socket.sock")); let result = client.trigger("linear", "LIN-1").await; assert!(result.is_err()); } diff --git a/crates/claudear-engine/src/ipc/mod.rs b/crates/claudear-engine/src/ipc/mod.rs index a9178e4f..094d5786 100644 --- a/crates/claudear-engine/src/ipc/mod.rs +++ b/crates/claudear-engine/src/ipc/mod.rs @@ -1,57 +1,91 @@ -//! Inter-process communication via Unix socket. +//! Inter-process communication for the watcher daemon. //! -//! Enables the CLI to communicate with a running watcher daemon. +//! Platform-specific transport details (Unix domain sockets vs TCP) live in +//! the [`transport`] module. This file exposes the high-level helpers that the +//! rest of the crate uses for daemon lifecycle management. mod client; mod protocol; mod server; +pub(crate) mod transport; pub use client::{print_response, IpcClient}; pub use protocol::{IpcCommand, IpcData, IpcResponse, WatcherState}; pub use server::IpcServer; +use claudear_core::platform; use std::path::PathBuf; /// Returns a private runtime directory for IPC files, scoped to the current user. /// -/// On Linux, prefers `XDG_RUNTIME_DIR` (already user-private, typically mode 0700). -/// Otherwise, creates a subdirectory `claudear-{uid}` under the system temp dir -/// with mode 0700 to prevent other users from accessing the socket/PID files. +/// * **Linux** — prefers `XDG_RUNTIME_DIR` (already user-private, mode 0700). +/// * **Windows** — uses `%LOCALAPPDATA%\claudear` or falls back to `%TEMP%\claudear`. +/// * **macOS / other** — creates `claudear-{uid}` under the system temp dir with +/// mode 0700. fn ipc_runtime_dir() -> PathBuf { - // On Linux, XDG_RUNTIME_DIR is already user-private - if !cfg!(target_os = "macos") { - if let Ok(xdg) = std::env::var("XDG_RUNTIME_DIR") { - return PathBuf::from(xdg); + #[cfg(windows)] + { + // Prefer %LOCALAPPDATA%\claudear (user-private on Windows) + if let Ok(local_app_data) = std::env::var("LOCALAPPDATA") { + let dir = PathBuf::from(local_app_data).join("claudear"); + if !dir.exists() { + if let Err(e) = std::fs::create_dir_all(&dir) { + tracing::error!( + "Failed to create IPC runtime dir {:?}: {} — falling back to temp dir", + dir, + e + ); + return std::env::temp_dir().join("claudear"); + } + } + return dir; + } + let dir = std::env::temp_dir().join("claudear"); + if !dir.exists() { + let _ = std::fs::create_dir_all(&dir); } + dir } - // Fallback: create a user-scoped subdirectory in the temp dir with restricted permissions - let uid = unsafe { libc::getuid() }; - let dir = std::env::temp_dir().join(format!("claudear-{}", uid)); - if !dir.exists() { - if let Err(e) = std::fs::create_dir_all(&dir) { - // SECURITY: Falling back to the system temp dir is unsafe because it is - // world-readable, which could allow other users to access or tamper with - // the IPC socket. This should be treated as a critical failure. - tracing::error!("Failed to create IPC runtime dir {:?}: {} — falling back to world-readable temp dir", dir, e); - return std::env::temp_dir(); + #[cfg(not(windows))] + { + // On Linux, XDG_RUNTIME_DIR is already user-private + if !cfg!(target_os = "macos") { + if let Ok(xdg) = std::env::var("XDG_RUNTIME_DIR") { + return PathBuf::from(xdg); + } } - // Set directory permissions to 0700 (owner-only) - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let perms = std::fs::Permissions::from_mode(0o700); - if let Err(e) = std::fs::set_permissions(&dir, perms) { + + // Fallback: create a user-scoped subdirectory in the temp dir + let uid = unsafe { libc::getuid() }; + let dir = std::env::temp_dir().join(format!("claudear-{}", uid)); + if !dir.exists() { + if let Err(e) = std::fs::create_dir_all(&dir) { + tracing::error!( + "Failed to create IPC runtime dir {:?}: {} — falling back to world-readable temp dir", + dir, + e + ); + return std::env::temp_dir(); + } + if let Err(e) = platform::set_dir_permissions_secure(&dir) { tracing::warn!("Failed to set IPC runtime dir permissions: {}", e); } } + dir } - dir } -/// Default socket path for the IPC server. +/// Default socket path for the IPC server (Unix) or port file path (Windows). pub fn default_socket_path() -> PathBuf { - ipc_runtime_dir().join("claudear.sock") + #[cfg(windows)] + { + ipc_runtime_dir().join("claudear.port") + } + #[cfg(not(windows))] + { + ipc_runtime_dir().join("claudear.sock") + } } /// Default PID file path. @@ -61,8 +95,7 @@ pub fn default_pid_path() -> PathBuf { /// Check if a watcher daemon is running. pub fn is_daemon_running() -> bool { - let socket_path = default_socket_path(); - socket_path.exists() && std::os::unix::net::UnixStream::connect(&socket_path).is_ok() + transport::check_connection(&default_socket_path()) } /// Get the PID of the running daemon, if any. @@ -89,7 +122,7 @@ pub fn remove_pid_file() { let _ = std::fs::remove_file(&pid_path); } -/// Remove the socket file. +/// Remove the socket file (Unix) or port file (Windows). pub fn remove_socket_file() { let socket_path = default_socket_path(); let _ = std::fs::remove_file(&socket_path); @@ -98,59 +131,35 @@ pub fn remove_socket_file() { /// Clean up stale socket/pid files from a previous crash. pub fn cleanup_stale_files() { if let Some(pid) = get_daemon_pid() { - // Check if process is still running by trying to read /proc/{pid} or using kill -0 - let is_running = is_process_running(pid); - if !is_running { + if !platform::is_process_running(pid) { tracing::info!("Cleaning up stale files from previous run (PID {})", pid); remove_pid_file(); remove_socket_file(); } } else { - // No PID file but socket exists - stale + // No PID file but socket/port file exists — check if stale let socket_path = default_socket_path(); - if socket_path.exists() && std::os::unix::net::UnixStream::connect(&socket_path).is_err() { + if transport::is_stale(&socket_path) { tracing::info!("Cleaning up stale socket file"); remove_socket_file(); } } } -/// Check if a process with the given PID is running. -fn is_process_running(pid: u32) -> bool { - // Try to check /proc on Linux - #[cfg(target_os = "linux")] - { - std::path::Path::new(&format!("/proc/{}", pid)).exists() - } - - // On macOS/BSD, use kill(pid, 0) to check if process exists - #[cfg(target_os = "macos")] - { - // SAFETY: kill with signal 0 doesn't actually send a signal, - // it just checks if the process exists and we have permission to signal it. - // Returns 0 if process exists, -1 if not (with errno set to ESRCH). - match i32::try_from(pid) { - Ok(pid_i32) => unsafe { libc::kill(pid_i32, 0) == 0 }, - Err(_) => false, // PID exceeds i32::MAX, cannot be valid - } - } - - // Fallback for other platforms - #[cfg(not(any(target_os = "linux", target_os = "macos")))] - { - let _ = pid; // Suppress unused warning - // Assume running if we can't check - true - } -} - #[cfg(test)] mod tests { use super::*; #[test] - fn test_default_socket_path_ends_with_claudear_sock() { + fn test_default_socket_path_has_expected_extension() { let path = default_socket_path(); + #[cfg(windows)] + assert!( + path.ends_with("claudear.port"), + "Expected socket path to end with 'claudear.port', got: {:?}", + path + ); + #[cfg(not(windows))] assert!( path.ends_with("claudear.sock"), "Expected socket path to end with 'claudear.sock', got: {:?}", @@ -180,12 +189,7 @@ mod tests { #[test] fn test_get_daemon_pid_returns_option() { - // This tests that get_daemon_pid does not panic and returns an Option. - // If no PID file exists, it returns None. If one does exist (from a running - // daemon), it returns Some(pid). Either outcome is acceptable. let result = get_daemon_pid(); - // Just verify the function completes without panicking. - // If a daemon happens to be running, the PID should be > 0. if let Some(pid) = result { assert!(pid > 0, "PID should be positive, got: {}", pid); } @@ -193,23 +197,16 @@ mod tests { #[test] fn test_write_and_remove_pid_file_does_not_panic() { - // We avoid actually writing the PID file if a daemon is running, - // as that could interfere with the running daemon. Instead, we test - // the functions only when no daemon is active. if is_daemon_running() { - // A daemon is running; skip this test to avoid interfering. return; } - // Save any existing PID file content so we can restore it. let pid_path = default_pid_path(); let existing_content = std::fs::read_to_string(&pid_path).ok(); - // Write our PID file. let write_result = write_pid_file(); assert!(write_result.is_ok(), "write_pid_file should succeed"); - // Verify get_daemon_pid returns our PID. let read_pid = get_daemon_pid(); assert_eq!( read_pid, @@ -217,12 +214,9 @@ mod tests { "get_daemon_pid should return our process PID after write_pid_file" ); - // Remove the PID file. remove_pid_file(); - // Verify the PID file is gone (or restore the original if there was one). if let Some(content) = existing_content { - // Restore original content for the running daemon. let _ = std::fs::write(&pid_path, content); } else { assert!( @@ -234,50 +228,19 @@ mod tests { #[test] fn test_remove_socket_file_does_not_panic_when_no_socket() { - // remove_socket_file should silently succeed even if no socket file exists. - // We cannot unconditionally call it because a daemon might be using the socket. - // Instead, we verify a remove on a non-existent path is safe by checking the - // implementation pattern (let _ = remove_file), or we call it only when no daemon - // is running. if !is_daemon_running() { - // The socket file may or may not exist; either way this should not panic. remove_socket_file(); } } #[test] fn test_is_daemon_running_returns_bool() { - // When no daemon is running, this should return false. - // If a daemon happens to be running (e.g., in a dev environment), true is also valid. let result = is_daemon_running(); - // We simply verify the function completes and returns a bool. let _: bool = result; } - #[test] - fn test_is_process_running_with_own_pid() { - // Our own process is guaranteed to be running and we have permission to signal it. - let own_pid = std::process::id(); - assert!( - is_process_running(own_pid), - "Our own PID ({}) should be reported as running", - own_pid - ); - } - - #[test] - fn test_is_process_running_with_invalid_pid() { - // u32::MAX is extremely unlikely to be a valid PID on any system. - assert!( - !is_process_running(u32::MAX), - "PID u32::MAX should not be reported as running" - ); - } - #[test] fn test_cleanup_stale_files_does_not_panic() { - // cleanup_stale_files should handle all cases gracefully: - // no PID file, stale socket, running daemon, etc. cleanup_stale_files(); } } diff --git a/crates/claudear-engine/src/ipc/server.rs b/crates/claudear-engine/src/ipc/server.rs index e09c5f72..ec4a26fa 100644 --- a/crates/claudear-engine/src/ipc/server.rs +++ b/crates/claudear-engine/src/ipc/server.rs @@ -1,13 +1,18 @@ -//! IPC server implementation using Unix sockets. +//! IPC server implementation. +//! +//! Transport details (Unix sockets vs TCP) are abstracted by the +//! [`super::transport`] module — this file is platform-agnostic. use super::protocol::{ ActivityEntry, ActivityType, IpcCommand, IpcData, IpcResponse, WatcherState, }; +use super::transport::{self, IpcStream}; use super::{ cleanup_stale_files, default_socket_path, remove_pid_file, remove_socket_file, write_pid_file, }; use crate::watcher::Watcher; use claudear_core::error::Result; +use claudear_core::platform; use claudear_core::types::{ActivityLogEntry, FixAttemptStatus}; use claudear_integrations::notifier::Notifier; use claudear_integrations::source::IssueSource; @@ -19,7 +24,6 @@ use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}; use std::sync::Arc; use std::time::Instant; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; -use tokio::net::{UnixListener, UnixStream}; use tokio::sync::{broadcast, Mutex, RwLock, Semaphore}; /// Default maximum number of activity entries to keep (can be overridden via config). @@ -28,7 +32,7 @@ const DEFAULT_MAX_ACTIVITY_ENTRIES: usize = 10_000; /// Maximum number of concurrent IPC connections. const MAX_CONCURRENT_CONNECTIONS: usize = 64; -/// IPC server that listens on a Unix socket. +/// IPC server that listens on a Unix socket (or TCP on Windows). pub struct IpcServer { socket_path: PathBuf, tracker: Arc, @@ -237,7 +241,7 @@ impl IpcServer { // Clean up any stale files from previous runs cleanup_stale_files(); - // Remove existing socket file if present + // Remove existing socket/port file if present if self.socket_path.exists() { std::fs::remove_file(&self.socket_path)?; } @@ -245,17 +249,12 @@ impl IpcServer { // Write PID file write_pid_file()?; - // Bind to socket - let listener = UnixListener::bind(&self.socket_path)?; + // Bind the IPC listener (Unix socket or TCP — handled by transport) + let listener = transport::bind(&self.socket_path)?; tracing::info!("IPC server listening on {:?}", self.socket_path); - // Set permissions (owner only) - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let perms = std::fs::Permissions::from_mode(0o600); - std::fs::set_permissions(&self.socket_path, perms)?; - } + // Restrict socket file permissions on Unix (no-op on Windows) + platform::set_file_permissions_secure(&self.socket_path)?; let mut shutdown_rx = self.shutdown_tx.subscribe(); let conn_semaphore = Arc::new(Semaphore::new(MAX_CONCURRENT_CONNECTIONS)); @@ -315,9 +314,9 @@ impl IpcServer { } } -/// Handle a single IPC connection. +/// Handle a single IPC connection (platform-agnostic via [`IpcStream`]). async fn handle_connection( - stream: UnixStream, + stream: IpcStream, tracker: Arc, sources: Vec>, notifier: Arc, diff --git a/crates/claudear-engine/src/ipc/transport.rs b/crates/claudear-engine/src/ipc/transport.rs new file mode 100644 index 00000000..dbbca6d3 --- /dev/null +++ b/crates/claudear-engine/src/ipc/transport.rs @@ -0,0 +1,121 @@ +//! Platform-abstracted IPC transport. +//! +//! On Unix, uses Unix domain sockets. On Windows, uses TCP on localhost with +//! a port file for daemon discovery. +//! +//! Consumers import [`IpcListener`], [`IpcStream`], [`connect`], [`bind`], and +//! [`check_connection`] without any `#[cfg]` in their own code. + +use std::io; +use std::path::Path; + +// --------------------------------------------------------------------------- +// Type aliases — one concrete type per platform, transparent to callers +// --------------------------------------------------------------------------- + +#[cfg(not(windows))] +pub type IpcListener = tokio::net::UnixListener; +#[cfg(windows)] +pub type IpcListener = tokio::net::TcpListener; + +#[cfg(not(windows))] +pub type IpcStream = tokio::net::UnixStream; +#[cfg(windows)] +pub type IpcStream = tokio::net::TcpStream; + +// --------------------------------------------------------------------------- +// Connection helpers +// --------------------------------------------------------------------------- + +/// Connect to an IPC endpoint at `path`. +/// +/// * **Unix** — connects to the Unix domain socket at `path`. +/// * **Windows** — reads a TCP port number from `path` and connects to +/// `127.0.0.1:`. +pub async fn connect(path: &Path) -> io::Result { + #[cfg(not(windows))] + { + IpcStream::connect(path).await + } + #[cfg(windows)] + { + let port = read_port(path)?; + IpcStream::connect(("127.0.0.1", port)).await + } +} + +/// Bind an IPC listener at `path`. +/// +/// * **Unix** — binds a Unix domain socket at `path`. +/// * **Windows** — binds TCP on `127.0.0.1:0` (ephemeral port) and writes the +/// assigned port to `path`. +pub fn bind(path: &Path) -> io::Result { + #[cfg(not(windows))] + { + IpcListener::bind(path) + } + #[cfg(windows)] + { + // Use std to bind synchronously so we get the port immediately. + let std_listener = std::net::TcpListener::bind("127.0.0.1:0")?; + let port = std_listener.local_addr()?.port(); + write_port(path, port)?; + std_listener.set_nonblocking(true)?; + IpcListener::from_std(std_listener) + } +} + +/// Synchronously check whether a daemon is reachable at `path`. +/// +/// Returns `true` if a connection can be established. +pub fn check_connection(path: &Path) -> bool { + if !path.exists() { + return false; + } + #[cfg(not(windows))] + { + std::os::unix::net::UnixStream::connect(path).is_ok() + } + #[cfg(windows)] + { + read_port(path) + .map(|port| std::net::TcpStream::connect(("127.0.0.1", port)).is_ok()) + .unwrap_or(false) + } +} + +/// Check whether the IPC endpoint at `path` is stale (file exists but nobody +/// is listening). +pub fn is_stale(path: &Path) -> bool { + if !path.exists() { + return false; + } + #[cfg(not(windows))] + { + std::os::unix::net::UnixStream::connect(path).is_err() + } + #[cfg(windows)] + { + read_port(path) + .map(|port| std::net::TcpStream::connect(("127.0.0.1", port)).is_err()) + .unwrap_or(true) + } +} + +// --------------------------------------------------------------------------- +// Port file helpers (Windows only) +// --------------------------------------------------------------------------- + +#[cfg(windows)] +pub(crate) fn read_port(path: &Path) -> io::Result { + let contents = std::fs::read_to_string(path)?; + contents + .trim() + .parse() + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e)) +} + +#[cfg(windows)] +fn write_port(path: &Path, port: u16) -> io::Result<()> { + std::fs::write(path, port.to_string()) +} diff --git a/crates/claudear-integrations/src/chat/llm.rs b/crates/claudear-integrations/src/chat/llm.rs index a5bc5ffb..ec77c0bd 100644 --- a/crates/claudear-integrations/src/chat/llm.rs +++ b/crates/claudear-integrations/src/chat/llm.rs @@ -430,7 +430,7 @@ mod tests { #[test] fn test_validate_model_path_directory() { - let result = validate_model_path(&PathBuf::from("/tmp")); + let result = validate_model_path(&std::env::temp_dir()); assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("not a file")); } diff --git a/crates/claudear-integrations/src/github_app/routes.rs b/crates/claudear-integrations/src/github_app/routes.rs index aa3fc090..a1580676 100644 --- a/crates/claudear-integrations/src/github_app/routes.rs +++ b/crates/claudear-integrations/src/github_app/routes.rs @@ -286,13 +286,8 @@ fn save_credentials(creds: &ManifestConversionResponse, base_url: &str) -> Resul )) })?; - // Set restrictive permissions on the PEM file (Unix only) - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let perms = std::fs::Permissions::from_mode(0o600); - fs::set_permissions(pem_path, perms).ok(); - } + // Set restrictive permissions on the PEM file + claudear_core::platform::set_file_permissions_secure(pem_path.as_ref()).ok(); // Update .env file let env_path = Path::new(".env"); diff --git a/crates/claudear-integrations/src/notifier/email.rs b/crates/claudear-integrations/src/notifier/email.rs index c08b0ccb..17b3b691 100644 --- a/crates/claudear-integrations/src/notifier/email.rs +++ b/crates/claudear-integrations/src/notifier/email.rs @@ -39,7 +39,13 @@ impl EmailNotifier { AsyncSmtpTransport::::builder_dangerous(host) }; - Some(builder.port(config.smtp_port).credentials(creds).build()) + Some( + builder + .port(config.smtp_port) + .credentials(creds) + .timeout(Some(std::time::Duration::from_secs(10))) + .build(), + ) } else { None }; diff --git a/crates/claudear-storage/src/vectorlite.rs b/crates/claudear-storage/src/vectorlite.rs index 4d89d835..606c5176 100644 --- a/crates/claudear-storage/src/vectorlite.rs +++ b/crates/claudear-storage/src/vectorlite.rs @@ -45,6 +45,8 @@ pub fn try_load_vectorlite(conn: &Connection) -> Result { // Local development "./vectorlite.so", "./vectorlite.dylib", + // Windows paths + "./vectorlite.dll", ]; for path in common_paths { diff --git a/src/lib.rs b/src/lib.rs index aedc6646..2b46bf2a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -25,9 +25,19 @@ //! claudear webhook //! ``` +/// Install the ring crypto provider for all rustls consumers (reqwest, axum-server, etc.). +/// +/// Must be called once before any TLS connections are made. Subsequent calls +/// are harmless (the function is idempotent). +pub fn init_tls() { + // `install_default` returns Err if a provider is already installed – that's fine. + let _ = rustls::crypto::ring::default_provider().install_default(); +} + // Re-exported from claudear-core pub use claudear_core::error; pub use claudear_core::http; +pub use claudear_core::platform; pub use claudear_core::secret; pub use claudear_core::templates; pub use claudear_core::types; diff --git a/src/main.rs b/src/main.rs index 4485af05..5d112b6f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1595,6 +1595,9 @@ fn daemonize() -> anyhow::Result<()> { } fn main() -> anyhow::Result<()> { + // Install ring as the global TLS crypto provider (must happen before any HTTPS calls) + claudear::init_tls(); + // Resolve the user's login shell PATH so that user-local binaries // (e.g. claude via nvm/node) are discoverable when started via systemd. enrich_path_from_login_shell(); diff --git a/src/platform.rs b/src/platform.rs new file mode 100644 index 00000000..ef68a972 --- /dev/null +++ b/src/platform.rs @@ -0,0 +1,151 @@ +//! Platform abstraction layer. +//! +//! Centralises all OS-specific logic so that the rest of the codebase can stay +//! platform-agnostic. Every function is a no-op or a sensible default on +//! platforms where the underlying primitive does not exist. + +use std::path::Path; + +// --------------------------------------------------------------------------- +// File permissions +// --------------------------------------------------------------------------- + +/// Set restrictive **file** permissions (Unix `0o600` — owner read/write only). +/// +/// On Windows this is a no-op; NTFS ACLs are inherited from the parent +/// directory and are typically sufficient for single-user machines. +pub fn set_file_permissions_secure(path: &Path) -> std::io::Result<()> { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)) + } + #[cfg(not(unix))] + { + let _ = path; + Ok(()) + } +} + +/// Set restrictive **directory** permissions (Unix `0o700` — owner only). +/// +/// On Windows this is a no-op. +pub fn set_dir_permissions_secure(path: &Path) -> std::io::Result<()> { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700)) + } + #[cfg(not(unix))] + { + let _ = path; + Ok(()) + } +} + +// --------------------------------------------------------------------------- +// Process detection +// --------------------------------------------------------------------------- + +/// Check whether a process with the given PID is still alive. +pub fn is_process_running(pid: u32) -> bool { + #[cfg(target_os = "linux")] + { + std::path::Path::new(&format!("/proc/{}", pid)).exists() + } + + #[cfg(target_os = "macos")] + { + // SAFETY: kill with signal 0 doesn't actually send a signal, + // it just checks if the process exists and we have permission to signal it. + match i32::try_from(pid) { + Ok(pid_i32) => unsafe { libc::kill(pid_i32, 0) == 0 }, + Err(_) => false, + } + } + + #[cfg(target_os = "windows")] + { + use windows_sys::Win32::Foundation::CloseHandle; + use windows_sys::Win32::System::Threading::{ + OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, + }; + // SAFETY: OpenProcess with PROCESS_QUERY_LIMITED_INFORMATION is a + // read-only check. We immediately close the handle afterwards. + unsafe { + let handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid); + if !handle.is_null() { + CloseHandle(handle); + true + } else { + false + } + } + } + + #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] + { + let _ = pid; + true // assume running when we cannot check + } +} + +// --------------------------------------------------------------------------- +// Command / binary detection +// --------------------------------------------------------------------------- + +/// Check whether a command exists on the system PATH. +/// +/// Uses `which` on Unix and `where` on Windows. +pub fn command_exists(binary: &str) -> bool { + #[cfg(not(windows))] + let cmd = "which"; + #[cfg(windows)] + let cmd = "where"; + + std::process::Command::new(cmd) + .arg(binary) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_is_process_running_own_pid() { + assert!(is_process_running(std::process::id())); + } + + #[test] + fn test_is_process_running_invalid_pid() { + assert!(!is_process_running(u32::MAX)); + } + + #[test] + fn test_command_exists_known_binary() { + #[cfg(not(windows))] + assert!(command_exists("ls")); + #[cfg(windows)] + assert!(command_exists("cmd")); + } + + #[test] + fn test_command_exists_nonexistent() { + assert!(!command_exists("__nonexistent_binary_12345__")); + } + + #[test] + fn test_set_file_permissions_secure_nonexistent_path() { + let result = set_file_permissions_secure(Path::new("/tmp/__does_not_exist_12345__")); + // On Unix this should fail; on Windows it's a no-op (Ok). + #[cfg(unix)] + assert!(result.is_err()); + #[cfg(not(unix))] + assert!(result.is_ok()); + } +}