diff --git a/.gitattributes b/.gitattributes index 02adc63..6af7433 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,4 @@ * text=auto eol=lf *.md text eol=lf +*.ico binary +tests/fuzz/corpus/** -text diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..b658cee --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,3 @@ +# Every path is owned by the maintainer; 04 section 6 (TM-R39) requires this review at least for .github/, +# installer/, cmake/SdSign.cmake and tools/sd-release/. +* @FacuM diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e8378df..5858a17 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,7 +1,10 @@ # CI (04 §11.2): build + unit tests + import audit for x64-modern, x64-xp, x86-ia32 and x86-sse2, plus the # x64-analysis (/analyze) build. The x86 flavours also run the ISA audit and the constant-time helper-call # gate (sd-isacheck) as post-build steps and CTests; their test binaries run under WOW64 on the x64 -# runner (Dokany tests skip there). TM-R39: first-party actions only, pinned by commit SHA; read-only token. +# runner (Dokany tests skip there). The arm64 and arm32 entries are build-only (M6, 04 §1.1, §5.1): they +# cross-compile on the x64 runner, the build-time tools run from a nested x64 build, and no test runs (ARM +# binaries cannot run there; the ARM64 test job belongs to windows-11-arm, §11.2; arm32 has no test job, D5). +# TM-R39: first-party actions only, pinned by commit SHA; read-only token. name: ci on: @@ -24,7 +27,7 @@ jobs: strategy: fail-fast: false matrix: - preset: [x64-modern, x64-xp, x86-ia32, x86-sse2] + preset: [x64-modern, x64-xp, x86-ia32, x86-sse2, arm64, arm32] env: SD_BUILD_NUMBER: ${{ github.run_number }} steps: @@ -34,7 +37,7 @@ jobs: persist-credentials: false - name: Set up v141_xp toolset - if: matrix.preset != 'x64-modern' + if: matrix.preset != 'x64-modern' && matrix.preset != 'arm64' && matrix.preset != 'arm32' uses: ./.github/actions/setup-xp-toolset - name: Configure @@ -51,13 +54,16 @@ jobs: shell: pwsh run: cmake --build --preset ${{ matrix.preset }}-debug + # arm64 and arm32 are build-only (ARM test binaries cannot run on the x64 runner, 04 §11.4): ctest is skipped. - name: Test Release + if: matrix.preset != 'arm64' && matrix.preset != 'arm32' shell: pwsh - run: ctest --preset ${{ matrix.preset }} --output-junit "${{ github.workspace }}/build/ctest-${{ matrix.preset }}.xml" + run: ctest --preset ${{ matrix.preset }} --output-on-failure --output-junit "${{ github.workspace }}/build/ctest-${{ matrix.preset }}.xml" - name: Test Debug + if: matrix.preset != 'arm64' && matrix.preset != 'arm32' shell: pwsh - run: ctest --preset ${{ matrix.preset }}-debug + run: ctest --preset ${{ matrix.preset }}-debug --output-on-failure - name: Upload binaries uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 @@ -89,4 +95,20 @@ jobs: - name: Test shell: pwsh - run: ctest --preset x64-analysis + run: ctest --preset x64-analysis --output-on-failure + + # TM-R39 / TST-32: the workflow lint (tools/check-workflows.ps1). Read-only; CODEOWNERS is reported as a warning + # until the maintainer review owner is recorded. + workflow-lint: + name: workflow lint + runs-on: ${{ vars.SD_BUILD_RUNNER || 'windows-2022' }} + timeout-minutes: 10 + steps: + - name: Check out + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Lint the workflows + shell: pwsh + run: ./tools/check-workflows.ps1 diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml new file mode 100644 index 0000000..6969049 --- /dev/null +++ b/.github/workflows/nightly.yml @@ -0,0 +1,130 @@ +# Nightly checks (docs/design/04 §11.2; M6 notes in docs/dev/m6-notes.md): libFuzzer runs of the parser targets with a +# time budget per target, and the dudect-style constant-time timing tests (tests/ct, D1; SD_CT_TIMING). +# +# fuzz one job per target (tests/fuzz/CMakeLists.txt, sd_add_fuzz_target): the x86-fuzz preset (libFuzzer and +# AddressSanitizer, never shipped), 600 s per target, the repository corpus copied to a scratch folder first so +# the repository is never written. A crash or a timeout uploads its input and fails the job. +# ct dudect timing tests on the quiet-machine flavours of the matrix (x86-ia32, x64-modern). Hosted runners are +# noisy: a failure here is a signal to rerun on the lab machine (04 §11.2), not a release decision. +# lint the workflow lint (tools/check-workflows.ps1): read-only, runs on every night as well. +# +# Secrets: none are referenced and no cache is restored (TM-R39). Actions: first-party, pinned by commit SHA, the +# same pins as ci.yml. Token: read-only. Triggers: schedule and workflow_dispatch only; no pull_request trigger. + +name: nightly + +on: + schedule: + - cron: "17 3 * * *" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: nightly-${{ github.ref }} + cancel-in-progress: false + +jobs: + workflow-lint: + name: workflow lint + runs-on: windows-2022 + timeout-minutes: 10 + steps: + - name: Check out + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Lint the workflows + shell: pwsh + run: ./tools/check-workflows.ps1 + + fuzz: + name: fuzz ${{ matrix.target }} + runs-on: windows-2022 + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + target: [json, ini, utf_names, text_sanitize, keyfile, known_hosts, cert, kexinit, sftp_reply, wd_http, wd_xml, state, manifest, inflate] + steps: + - name: Check out + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Configure (x86-fuzz) + shell: pwsh + run: cmake --preset x86-fuzz + + - name: Build the libFuzzer target + shell: pwsh + run: cmake --build build/x86-fuzz --config Debug --target sd-fuzz-${{ matrix.target }}-libfuzzer + + - name: Run for 600 seconds on a scratch copy of the corpus + shell: pwsh + env: + SD_FUZZ_TARGET: ${{ matrix.target }} + run: | + $exe = Get-ChildItem -Path build/x86-fuzz -Recurse -Filter "sd-fuzz-$env:SD_FUZZ_TARGET-libfuzzer.exe" | Select-Object -First 1 + if (-not $exe) { throw "libFuzzer target not found" } + $work = Join-Path $env:RUNNER_TEMP "corpus-$env:SD_FUZZ_TARGET" + New-Item -ItemType Directory -Force -Path $work | Out-Null + Copy-Item -Path (Join-Path "tests/fuzz/corpus" $env:SD_FUZZ_TARGET) -Destination $work -Recurse -Force + $artifacts = Join-Path $env:RUNNER_TEMP "fuzz-artifacts-$env:SD_FUZZ_TARGET" + New-Item -ItemType Directory -Force -Path $artifacts | Out-Null + Push-Location $artifacts + try { + & $exe.FullName (Join-Path $work $env:SD_FUZZ_TARGET) -max_total_time=600 -timeout=20 "-artifact_prefix=$artifacts/" + if ($LASTEXITCODE -ne 0) { throw "fuzzer exit $LASTEXITCODE" } + } finally { + Pop-Location + } + + - name: Upload crashes and timeouts + if: failure() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: fuzz-crash-${{ matrix.target }} + path: ${{ runner.temp }}/fuzz-artifacts-${{ matrix.target }} + if-no-files-found: ignore + retention-days: 30 + + ct: + name: dudect (${{ matrix.preset }}) + runs-on: windows-2022 + timeout-minutes: 90 + strategy: + fail-fast: false + matrix: + preset: [x86-ia32, x64-modern] + steps: + - name: Check out + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Configure with the timing tests + shell: pwsh + run: cmake --preset ${{ matrix.preset }} "-DSD_CT_TIMING=ON" + + - name: Build sd-ct-dudect + shell: pwsh + run: cmake --build build/${{ matrix.preset }} --config Release --target sd-ct-dudect + + - name: Run the timing tests (all primitives, default samples) + shell: pwsh + run: | + $exe = Join-Path "build/${{ matrix.preset }}/bin/Release" "sd-ct-dudect.exe" + & $exe 2>&1 | Tee-Object -FilePath "ct-${{ matrix.preset }}.txt" + if ($LASTEXITCODE -ne 0) { throw "dudect reports a timing difference or bad inputs (exit $LASTEXITCODE)" } + + - name: Upload the timing report + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: ct-${{ matrix.preset }} + path: ct-${{ matrix.preset }}.txt + if-no-files-found: ignore + retention-days: 30 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..6497997 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,198 @@ +# Release pipeline skeleton (docs/design/04 §12.3, §11.2; D42, TM-R38, TM-R39). +# +# Status: SKELETON. The jobs that build and test the release presets are complete, and check-gates validates the release +# gates record (installer/keys/release-gates.txt, 04 §12.3) before the signing sessions. The jobs after package stop with +# an error until the parts they need exist in the repository (installer/dokany/dokany.lock, payload packaging; see +# docs/dev/m4-notes.md). Every such step fails closed, so no draft release can come out of this file yet. +# +# Secrets: none are referenced. Signing never runs in CI. The two offline signing sessions (D42) are maintainer steps on +# the offline machine; the jobs signing-session-1 and signing-session-2 are approval gates (a protected environment with a +# required reviewer, configured in the repository settings) and print the checklist of 04 §12.3. +# +# Actions: only first-party actions, pinned by commit SHA (TM-R39, same pins as ci.yml). No actions/cache is used. +# Triggers: workflow_dispatch only. The tag trigger (v*) is added when the signed-tag gate exists (TM-R39). + +name: release + +on: + workflow_dispatch: + inputs: + version: + description: "Version to release (must match VERSION, for example 0.1.0)" + required: true + type: string + +permissions: + contents: read + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +jobs: + verify-gates: + name: verify version and release gates + runs-on: ${{ vars.SD_BUILD_RUNNER || 'windows-2022' }} + timeout-minutes: 15 + env: + SD_INPUT_VERSION: ${{ inputs.version }} + steps: + - name: Check out + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Check the version input against VERSION + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $fileVersion = (Get-Content -Raw VERSION).Trim() + if ($fileVersion -ne $env:SD_INPUT_VERSION) { + throw "VERSION is '$fileVersion' but the input is '$env:SD_INPUT_VERSION'." + } + + build: + name: build (${{ matrix.preset }}) + needs: verify-gates + runs-on: ${{ vars.SD_BUILD_RUNNER || 'windows-2022' }} + timeout-minutes: 60 + strategy: + fail-fast: true + matrix: + preset: [x64-modern, x64-xp, x86-ia32, x86-sse2, arm64, arm32] + env: + SD_BUILD_NUMBER: ${{ github.run_number }} + steps: + - name: Check out + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Set up v141_xp toolset + if: matrix.preset != 'x64-modern' && matrix.preset != 'arm64' && matrix.preset != 'arm32' + uses: ./.github/actions/setup-xp-toolset + + - name: Configure + shell: pwsh + run: cmake --preset ${{ matrix.preset }} "-DSD_BUILD_NUMBER=${{ github.run_number }}" + + - name: Build Release + shell: pwsh + run: cmake --build --preset ${{ matrix.preset }} + + - name: Test Release + if: matrix.preset != 'arm64' && matrix.preset != 'arm32' + shell: pwsh + run: ctest --preset ${{ matrix.preset }} + + # Reproducible build (04 §12.2): two builds from one source tree compared byte for byte. Two presets only, the + # rest of the matrix runs the same check when the double build is affordable on the release runner. + - name: Reproducible build check + if: matrix.preset == 'x64-modern' || matrix.preset == 'x86-ia32' + shell: pwsh + run: powershell -NoProfile -ExecutionPolicy Bypass -File tools/check-repro.ps1 -Preset ${{ matrix.preset }} + + # Unsigned binaries only: the payload tables are signed in the offline session (D42), never here. + - name: Upload binaries + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: release-bin-${{ matrix.preset }} + path: | + build/${{ matrix.preset }}/bin/Release/*.exe + build/${{ matrix.preset }}/bin/Release/*.pdb + if-no-files-found: error + + package: + name: package (payload CABs, MSIs, setup EXEs) + needs: build + runs-on: ${{ vars.SD_BUILD_RUNNER || 'windows-2022' }} + timeout-minutes: 60 + steps: + - name: Check out + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + # Not implemented yet. Needed: installer/dokany/dokany.lock with the verified Dokany files (TM-R23, D32), the payload + # CAB and PAYLOAD_TABLE step (tools/sd-payload), the MSIs (packaging/msi, SD_BUILD_MSI with WiX 3.14 on this runner), + # sd-msilang transforms and the sd-release manifest tool (tools/sd-release does not exist yet). + - name: Stop until packaging exists + shell: pwsh + run: | + throw 'package is not implemented: payload packaging, the Dokany lock and tools/sd-release are not in the repository yet (docs/dev/m4-notes.md).' + + # 04 §12.3 release gates: sd-release validates installer/keys/release-gates.txt for this version and commit. The + # record is maintainer-edited and CODEOWNERS-protected; the check reads it and uses no secrets. Refusal fails the job. + check-gates: + name: check the release gates record (04 §12.3) + needs: build + runs-on: ${{ vars.SD_BUILD_RUNNER || 'windows-2022' }} + timeout-minutes: 30 + env: + SD_INPUT_VERSION: ${{ inputs.version }} + SD_COMMIT: ${{ github.sha }} + steps: + - name: Check out + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Build the sd-release tool + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + cmake --preset x64-modern + cmake --build --preset x64-modern --target sd-release + + - name: Check the release gates record + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $exe = Get-ChildItem -Path build/x64-modern -Recurse -Filter sd-release.exe | Select-Object -First 1 + if ($null -eq $exe) { throw 'sd-release.exe was not built.' } + & $exe.FullName gates check installer/keys/release-gates.txt --version $env:SD_INPUT_VERSION --commit $env:SD_COMMIT + if ($LASTEXITCODE -ne 0) { throw "release gates check failed (exit $LASTEXITCODE): see 04 section 12.3." } + + signing-session-1: + name: offline signing session 1 of 2 (maintainer, D42) + needs: [package, check-gates] + runs-on: ${{ vars.SD_BUILD_RUNNER || 'windows-2022' }} + timeout-minutes: 30 + environment: release-stable-session-1 + steps: + # The maintainer performs this session offline (04 §12.3 step 5): verify payload-tables.txt against the rebuilt + # payloads with sd-release verify, sign with the release-stable key, and hand the signatures to the finalize step. + # CI signs nothing here; the key never touches this runner. + - name: Checklist + shell: pwsh + run: | + Write-Output 'Offline session 1 of 2 (D42): verify payload-tables.txt, sign it on the offline machine, record the result.' + + finalize: + name: finalize (draft release, manually approved) + needs: signing-session-1 + runs-on: ${{ vars.SD_BUILD_RUNNER || 'windows-2022' }} + timeout-minutes: 60 + # The only job with write access: it creates the draft release and nothing else (TM-R39). + permissions: + contents: write + environment: release-finalize + steps: + - name: Stop until packaging exists + shell: pwsh + run: | + throw 'finalize is not implemented: PAYLOAD_SIG insertion, SHA256SUMS, manifest.txt and the draft release need the package job first.' + + signing-session-2: + name: offline signing session 2 of 2 (maintainer, D42) + needs: finalize + runs-on: ${{ vars.SD_BUILD_RUNNER || 'windows-2022' }} + timeout-minutes: 30 + environment: release-stable-session-2 + steps: + # 04 §12.3 step 7: verify manifest.txt and SHA256SUMS against the draft assets with sd-release verify, sign them + # with the release-stable key offline, and upload manifest.txt (signed) and SHA256SUMS.sig to the draft. + - name: Checklist + shell: pwsh + run: | + Write-Output 'Offline session 2 of 2 (D42): verify manifest.txt and SHA256SUMS against the draft, sign offline, upload the signed files.' diff --git a/AGENTS.md b/AGENTS.md index 58f095f..c98bbb7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,8 +9,8 @@ These rules apply to the whole repository. Component notes live next to the code ## Language - Commit messages, PR titles and descriptions, branch names, code identifiers, code comments, test names, - log messages and machine-readable output (JSON diagnostics, protocol strings, event log payloads) are - in English, whatever language the prompt uses. + log messages and machine-readable output (JSON diagnostics, protocol strings, event log insert strings + such as paths and reason codes) are in English, whatever language the prompt uses. - Documentation (`README.md`, `docs/`, `AGENTS.md` files, guides, checklists) is written in one language: English. Do not add translated copies of documents or prose in another language. - User-facing text is written in English first and fully localized into English, Spanish, French, @@ -18,8 +18,9 @@ These rules apply to the whole repository. Component notes live next to the code language across every component: Spanish with Rioplatense voseo ("Elegí"), Brazilian Portuguese, French with "vous", German and Italian with the informal imperative. - This covers the CLI (`--help`, user-facing errors, printed summaries), the tray app, the main window, - Explorer integration, the installer (EXE and MSI) and the service's user-visible messages. Logs and - JSON stay in English. + Explorer integration, the installer (EXE and MSI) and the service's user-visible messages, including + the message text of its Event Log entries (`l10n/svc`, one text per language under every sublanguage + LANGID; 03 §10.3). Logs, JSON and Event Log insert strings stay in English. - Use placeholders and plural-aware messages instead of string concatenation. - The resource format and loading mechanism for Win32 (XP-compatible) are defined in `docs/design/03-apps-platform.md` §9 "Localization" (§9.1 "Decision: our own message catalog compiled diff --git a/CMakeLists.txt b/CMakeLists.txt index bc083d6..2379167 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -20,17 +20,65 @@ include(SdOptions) # flavour, compiler and linker flags (04 §3.2) include(SdVersion) # VERSION file, git commit, generated sd/version.h include(SdModule) # sd_add_module, sd_add_unit_test, sd_add_app helpers include(SdL10n) # sdl10n catalog generation (03 §9) +include(SdHostTools) # build-time tools of cross-compiled (ARM64, ARM32) trees (04 §2.5) + +# Group Policy headers and ADMX/ADML outputs (03 §3.5); generated under generated/policy/. +set(SD_POLICY_DIR "${SD_GENERATED_DIR}/policy") enable_testing() -# Build-time tools first: sdl10n, sd-importcheck and sd-isacheck run during the build. -add_subdirectory(tools/sdl10n) -add_subdirectory(tools/sd-importcheck) -add_subdirectory(tools/sd-isacheck) +if(SD_HOST_TOOLS_ONLY) + # Nested x64 configure of an ARM64 or ARM32 tree: only the tools that run during the build (cmake/SdHostTools.cmake). + add_subdirectory(tools/sdl10n) + add_subdirectory(tools/sd-importcheck) + add_subdirectory(tools/sd-admxgen) + add_subdirectory(tools/sd-payload) + return() +endif() + +if(SD_SHELLEXT_ONLY) + # Nested x86 configure of an x64 tree (cmake/SdShellExtX86.cmake, 04 §6.2, §8.3): only the x86-sse2 shell extension + # and the modules it links (sd_crypto: the constant-time gate of sd-isacheck reads it; sd_policy and its generated + # headers: sd_l10n includes them). Its build-time tools are x86 programs, which an x64 host runs under WOW64. + add_subdirectory(tools/sdl10n) + add_subdirectory(tools/sd-importcheck) + add_subdirectory(tools/sd-isacheck) + add_subdirectory(tools/sd-admxgen) + add_subdirectory(src/sd_base) + add_subdirectory(src/sd_platform) + add_subdirectory(src/sd_crypto) + add_subdirectory(res/policy) + add_subdirectory(src/sd_policy) + add_subdirectory(src/sd_ipc) + add_subdirectory(src/sd_l10n) + add_subdirectory(l10n) + add_subdirectory(apps/shelldrive-shellext) + return() +endif() + +# Build-time tools first: sdl10n, sd-importcheck and sd-admxgen run during the build. An ARM64 or ARM32 tree takes +# them from the nested x64 build instead of building ARM copies that cannot run here. +if(SD_CROSS_HOST_TOOLS) + sd_host_tools_setup() +else() + add_subdirectory(tools/sdl10n) + add_subdirectory(tools/sd-importcheck) + add_subdirectory(tools/sd-admxgen) + add_subdirectory(tools/sd-payload) +endif() +# sd-isacheck audits x86 code only (04 §2.4): not part of the ARM64 or ARM32 trees. +if(NOT SD_ARM64 AND NOT SD_ARM32) + add_subdirectory(tools/sd-isacheck) +endif() +add_subdirectory(tools/sd-updsign) +add_subdirectory(tools/sd-release) # Test harness (needed by module unit tests). add_subdirectory(tests/harness) +# Group Policy template generation (needs sd-admxgen, built above). +add_subdirectory(res/policy) + # Modules, in dependency order (00 §5). add_subdirectory(src/sd_base) add_subdirectory(src/sd_platform) @@ -40,17 +88,36 @@ add_subdirectory(src/sd_sftp) add_subdirectory(src/sd_vfs) add_subdirectory(src/sd_dokan) add_subdirectory(src/sd_webdav) +add_subdirectory(src/sd_policy) add_subdirectory(src/sd_l10n) add_subdirectory(src/sd_config) +add_subdirectory(src/sd_secret) add_subdirectory(src/sd_ipc) +add_subdirectory(src/sd_state) add_subdirectory(src/sd_mount) +add_subdirectory(src/sd_update) # Localized catalogs (PO sources under l10n/). add_subdirectory(l10n) -# Applications. +# Applications. ARM32 ships no UI and no shell extension (D5, 04 §5.1: Windows RT runs no third-party desktop code and +# IoT images have no Explorer), so those two are not built there. add_subdirectory(apps/shelldrive) add_subdirectory(apps/shelldrive-host) +if(NOT SD_ARM32) + add_subdirectory(apps/shelldrive-ui) + add_subdirectory(apps/shelldrive-shellext) +endif() + +# Per-machine MSI packages (04 §8): opt-in, target msi, needs WiX 3.14 (packaging/msi/CMakeLists.txt). Added before the +# setup stub, because the stub's payload CAB carries the MSI when it is built (cmake/SdPayload.cmake). +option(SD_BUILD_MSI "Build the per-machine MSI of this flavour (target msi, WiX 3.14; 04 §8)" OFF) +if(SD_BUILD_MSI) + add_subdirectory(packaging/msi) +endif() + +# Setup engine, stub and payload (04 §6; tools/sd-payload attaches the payload CAB, added above). +add_subdirectory(apps/shelldrive-setup) # Unit tests and other test suites. add_subdirectory(tests) diff --git a/CMakePresets.json b/CMakePresets.json index 3641dbd..5a0df54 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -89,6 +89,58 @@ "SD_ENABLE_ANALYZE": "ON", "SD_ENABLE_ASAN": "OFF" } + }, + { + "name": "x86-fuzz", + "displayName": "x86 fuzz (v143 Win32, libFuzzer + ASan, DLL CRT; tests/fuzz, never shipped)", + "inherits": "x86-base", + "toolset": { + "value": "v143", + "strategy": "set" + }, + "cacheVariables": { + "SD_FLAVOUR": "x86-ia32", + "SD_X86_ARCH": "IA32", + "SD_FUZZ": "ON", + "SD_ENABLE_ASAN": "OFF" + } + }, + { + "name": "arm64", + "displayName": "ARM64 (v143, SDK 10.0.26100; cross-compiled, build only, no tests)", + "inherits": "base", + "architecture": { + "value": "ARM64", + "strategy": "set" + }, + "toolset": { + "value": "v143", + "strategy": "set" + }, + "cacheVariables": { + "SD_FLAVOUR": "arm64", + "CMAKE_SYSTEM_VERSION": "10.0.26100.0" + } + }, + { + "name": "arm32", + "displayName": "ARM32 (v143, SDK 10.0.22621; cross-compiled, build only, best effort D5, no tests)", + "inherits": "base", + "architecture": { + "value": "ARM", + "strategy": "set" + }, + "toolset": { + "value": "v143", + "strategy": "set" + }, + "cacheVariables": { + "SD_FLAVOUR": "arm32", + "CMAKE_SYSTEM_VERSION": "10.0.22621.0", + "CMAKE_C_COMPILER_WORKS": "1", + "CMAKE_C_ABI_COMPILED": "ON", + "CMAKE_C_STANDARD_LIBRARIES": "kernel32.lib user32.lib gdi32.lib winspool.lib shell32.lib ole32.lib oleaut32.lib uuid.lib comdlg32.lib advapi32.lib" + } } ], "buildPresets": [ @@ -132,13 +184,47 @@ "configurePreset": "x86-sse2", "configuration": "Debug" }, + { + "name": "x86-fuzz", + "configurePreset": "x86-fuzz", + "configuration": "Debug" + }, { "name": "x64-analysis", "configurePreset": "x64-analysis", "configuration": "Debug" + }, + { + "name": "arm64", + "configurePreset": "arm64", + "configuration": "Release" + }, + { + "name": "arm64-debug", + "configurePreset": "arm64", + "configuration": "Debug" + }, + { + "name": "arm32", + "configurePreset": "arm32", + "configuration": "Release" + }, + { + "name": "arm32-debug", + "configurePreset": "arm32", + "configuration": "Debug" } ], "testPresets": [ + { + "name": "x86-fuzz", + "inherits": "base", + "configurePreset": "x86-fuzz", + "configuration": "Debug", + "environment": { + "PATH": "C:\\Program Files (x86)\\Microsoft Visual Studio\\2022\\BuildTools\\VC\\Tools\\MSVC\\14.44.35207\\bin\\Hostx64\\x86;$penv{PATH}" + } + }, { "name": "base", "hidden": true, @@ -202,6 +288,34 @@ "inherits": "base", "configurePreset": "x64-analysis", "configuration": "Debug" + }, + { + "name": "arm64", + "description": "Build-only flavour: ARM64 test binaries cannot run on x64 hosts (04 section 11.4). This preset selects no tests, so ctest exits 0 and reports nothing to run.", + "configurePreset": "arm64", + "configuration": "Release", + "filter": { + "include": { + "name": "^$" + } + }, + "execution": { + "noTestsAction": "ignore" + } + }, + { + "name": "arm32", + "description": "Build-only flavour: ARM32 test binaries cannot run on x64 hosts (04 section 11.4). This preset selects no tests, so ctest exits 0 and reports nothing to run.", + "configurePreset": "arm32", + "configuration": "Release", + "filter": { + "include": { + "name": "^$" + } + }, + "execution": { + "noTestsAction": "ignore" + } } ] } diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..051923f --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 LibreStatic + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/apps/shelldrive-host/CMakeLists.txt b/apps/shelldrive-host/CMakeLists.txt index e70123c..a17ab39 100644 --- a/apps/shelldrive-host/CMakeLists.txt +++ b/apps/shelldrive-host/CMakeLists.txt @@ -6,6 +6,18 @@ set(SD_HOST_AGENT_SOURCES ${CMAKE_CURRENT_SOURCE_DIR}/agent.c + ${CMAKE_CURRENT_SOURCE_DIR}/log_ring.c + ${CMAKE_CURRENT_SOURCE_DIR}/log_ring.h + ${CMAKE_CURRENT_SOURCE_DIR}/state_producer.c + ${CMAKE_CURRENT_SOURCE_DIR}/state_producer.h + ${CMAKE_CURRENT_SOURCE_DIR}/explorer_data.c + ${CMAKE_CURRENT_SOURCE_DIR}/explorer_data.h + ${CMAKE_CURRENT_SOURCE_DIR}/config_watch.c + ${CMAKE_CURRENT_SOURCE_DIR}/update_ipc.c + ${CMAKE_CURRENT_SOURCE_DIR}/update_ipc.h + ${CMAKE_CURRENT_SOURCE_DIR}/config_watch.h + ${CMAKE_CURRENT_SOURCE_DIR}/agent_events.c + ${CMAKE_CURRENT_SOURCE_DIR}/agent_events.h ${CMAKE_CURRENT_SOURCE_DIR}/host.h) if(SD_HOST_REAL_BACKEND) @@ -16,8 +28,63 @@ else() set(_backend_lib "") endif() +# Event Log message table (03 §10.3): sdl10n writes svc_events.mc from the l10n/svc PO catalog (every event id in +# every locale, or the build fails), and mc.exe compiles it with -u (UTF-16 input) into one message table per +# language (en, es-AR, fr-FR, pt-BR, it-IT, de-DE). mc.exe ships with the Windows SDK next to the rc.exe of the active +# toolset. The generated header (message ids) is shared with the unit tests; the generated resource script is linked +# into shelldrive-host.exe, which is also the event message file. +set(_svc_mc_hints "$ENV{WindowsSdkVerBinPath}x64" "$ENV{WindowsSdkBinPath}") +if(CMAKE_RC_COMPILER) + get_filename_component(_svc_tool_dir "${CMAKE_RC_COMPILER}" DIRECTORY) + list(APPEND _svc_mc_hints "${_svc_tool_dir}") +endif() +set(_svc_pf86 "$ENV{SystemDrive}/Program Files (x86)") +list(APPEND _svc_mc_hints + "${_svc_pf86}/Windows Kits/10/bin/${CMAKE_SYSTEM_VERSION}/x64" + "${_svc_pf86}/Windows Kits/10/bin/${CMAKE_SYSTEM_VERSION}/x86" + "${_svc_pf86}/Microsoft SDKs/Windows/v7.1A/Bin") +find_program(SD_MC_EXE NAMES mc mc.exe HINTS ${_svc_mc_hints}) +if(NOT SD_MC_EXE) + message(FATAL_ERROR "mc.exe was not found (Windows SDK): the Event Log message table of shelldrive-host cannot be built") +endif() +set(SD_SVC_EVENTS_DIR "${SD_GENERATED_DIR}/svc_events/$") +set(SD_SVC_EVENTS_MC "${SD_SVC_EVENTS_DIR}/svc_events.mc") +# mc.exe writes one MSG.bin per LanguageNames entry that sdl10n defines (one per LANGID of the six primary +# languages, 58 in all). They are not listed as outputs: the .rc file names them, and the set is owned by sdl10n. +set(SD_SVC_EVENTS_PO "") +foreach(_lang IN LISTS SD_LANGS) + list(APPEND SD_SVC_EVENTS_PO "${CMAKE_SOURCE_DIR}/l10n/svc/${_lang}.po") +endforeach() +sd_tool_depends(sdl10n _sdl10n_dep) +add_custom_command( + OUTPUT "${SD_SVC_EVENTS_MC}" + COMMAND "${CMAKE_COMMAND}" -E make_directory "${SD_SVC_EVENTS_DIR}" + COMMAND "$" --out-mc "${SD_SVC_EVENTS_MC}" --domain "${CMAKE_SOURCE_DIR}/l10n/svc" + DEPENDS ${_sdl10n_dep} ${SD_SVC_EVENTS_PO} + COMMENT "sdl10n: Event Log messages of shelldrive-host" + VERBATIM) +add_custom_command( + OUTPUT "${SD_SVC_EVENTS_DIR}/svc_events.h" "${SD_SVC_EVENTS_DIR}/svc_events.rc" + COMMAND "${SD_MC_EXE}" -u -h "${SD_SVC_EVENTS_DIR}" -r "${SD_SVC_EVENTS_DIR}" "${SD_SVC_EVENTS_MC}" + DEPENDS "${SD_SVC_EVENTS_MC}" + COMMENT "mc: Event Log message tables of shelldrive-host (six languages)" + VERBATIM) +add_custom_target(sd_svc_events DEPENDS "${SD_SVC_EVENTS_DIR}/svc_events.h" "${SD_SVC_EVENTS_DIR}/svc_events.rc" "${SD_SVC_EVENTS_MC}") +sd_add_tool_dependency(sd_svc_events sdl10n) +set_target_properties(sd_svc_events PROPERTIES FOLDER "apps") + sd_add_app(shelldrive-host OUTPUT_NAME shelldrive-host DESCRIPTION "ShellDrive agent and service host" - SOURCES main.c ${SD_HOST_AGENT_SOURCES} ${_backend} - DEPS sd_mount ${_backend_lib} sd_ipc sd_config sd_platform sd_base) + SOURCES main.c ${SD_HOST_AGENT_SOURCES} ${_backend} ${CMAKE_CURRENT_SOURCE_DIR}/installer_hooks.c + ${CMAKE_CURRENT_SOURCE_DIR}/service.c ${CMAKE_CURRENT_SOURCE_DIR}/service.h + ${CMAKE_CURRENT_SOURCE_DIR}/svc_access.c ${CMAKE_CURRENT_SOURCE_DIR}/svc_access.h + ${CMAKE_CURRENT_SOURCE_DIR}/svc_folder.c ${CMAKE_CURRENT_SOURCE_DIR}/svc_folder.h + ${CMAKE_CURRENT_SOURCE_DIR}/svc_scm.c ${CMAKE_CURRENT_SOURCE_DIR}/svc_scm.h + ${CMAKE_CURRENT_SOURCE_DIR}/svc_repair.c ${CMAKE_CURRENT_SOURCE_DIR}/svc_repair.h + ${CMAKE_CURRENT_SOURCE_DIR}/svc_status.c ${CMAKE_CURRENT_SOURCE_DIR}/svc_status.h + "${SD_SVC_EVENTS_DIR}/svc_events.rc" + DEPS sd_mount ${_backend_lib} sd_ipc sd_state sd_secret sd_ssh sd_config sd_update sd_policy sd_platform sd_base + sd_l10n sd_l10n_catalog_svc ole32 shell32 user32 advapi32) +target_include_directories(shelldrive-host PRIVATE "${SD_SVC_EVENTS_DIR}") +add_dependencies(shelldrive-host sd_svc_events) diff --git a/apps/shelldrive-host/agent.c b/apps/shelldrive-host/agent.c index 7cd70a2..38df1c8 100644 --- a/apps/shelldrive-host/agent.c +++ b/apps/shelldrive-host/agent.c @@ -14,9 +14,24 @@ * cache, 03 §3.2), WebDAV mapping cleanup at start and stop (TM-R17 d) and in the crash filter * (TM-R17 b). * + * M2: the profile's connection chain (sd_chain_resolve: ProxyJump hops, a proxy per leg, identities, + * DPAPI-kept keys, agents, D37), the known_hosts store list with the live sources of settings.ini + * (5.1), legacy policy and the legacy_algorithms warning (filtered by security.legacy_accepted), + * the pool size, saved secrets (sd_secret: prompts answered from Credential Manager / DPAPI by + * sd_mount, "save" answers kept after a successful login with the comment the client localized), + * the new prompt kinds (password_change, legacy_ack, proxy_password) and UpdateHostKeys + * announcements: "hostkeys.announced" events, hostkey.announced.list / .accept (5.6). + * + * M3: the prompt broker (03 §2.5, §3.2, §3.3; docs/dev/m3-notes.md): every prompt has a record until its + * prompt.closed; routing to the initiator and the prompt UI subscribers with delivery accounting, a queue of 16 + * drained on subscribe, deadlines checked by the control thread once a second, and the initiator-disconnect rule. + * Also the read-only methods status, selftest, log.tail (log_ring.c), config.reload, prompt.list and the warnings of list. + * * M0 gaps (documented in docs/dev/m0-notes.md by the lead): no hidden window (WM_ENDSESSION, WTS * and power notifications), no prompt queue while locked, no UI launch for prompts, no autostart. */ #include +#include +#include #include #include "host.h" @@ -24,13 +39,61 @@ #include "sd_base/sd_mem.h" #include "sd_base/sd_utf.h" #include "sd_config/sd_config.h" +#include "sd_crypto/sd_crypto.h" #include "sd_ipc/sd_ipc.h" +#include "sd_policy/sd_policy.h" +#include "sd_secret/sd_secret.h" +#include "sd_ssh/sd_known_hosts.h" #include "sd_platform/sd_log.h" #include "sd_platform/sd_thread.h" #include "sd_platform/sd_time.h" +#include "log_ring.h" +#include "agent_events.h" +#include "state_producer.h" +#include "explorer_data.h" +#include "config_watch.h" +#include "update_ipc.h" +#include "sd_state/sd_state.h" + typedef struct work { struct work *next; void (*fn)(void *); void *arg; } work; +/* M2: what sd_mount_create needs for the chain; freed once sd_mount_create copied it. */ +typedef struct host_chain { + uint32_t count; + sd_mount_hop hop[SD_MOUNT_MAX_HOPS]; + wchar_t host[SD_MOUNT_MAX_HOPS][256]; + char user[SD_MOUNT_MAX_HOPS][128]; + wchar_t ident[SD_MOUNT_MAX_HOPS][SD_MOUNT_MAX_IDENTITIES][MAX_PATH]; + const wchar_t *ident_ptr[SD_MOUNT_MAX_HOPS][SD_MOUNT_MAX_IDENTITIES]; + wchar_t cert[SD_MOUNT_MAX_HOPS][MAX_PATH]; + char agent_keys[SD_MOUNT_MAX_HOPS][512]; + char alias[SD_MOUNT_MAX_HOPS][256]; + sd_secbuf *key[SD_MOUNT_MAX_HOPS]; + char key_label[SD_MOUNT_MAX_HOPS][64]; + wchar_t proxy_host[SD_MOUNT_MAX_HOPS][256]; + char proxy_user[SD_MOUNT_MAX_HOPS][128]; + sd_kh_store stores[4]; + uint32_t nstores; + wchar_t machine_kh[MAX_PATH]; +} host_chain; + +/* A pending UpdateHostKeys announcement (5.6, update_host_keys = ask). */ +typedef struct host_announce { + struct host_announce *next; + char id[16]; /* "a-" */ + char mount_id[16]; + sd_mount_announcement a; +} host_announce; + +/* M3 status proxy_legs: one proxy of the chain (no credentials). */ +typedef struct host_leg { + uint32_t hop; /* 1-based hop the leg reaches */ + int kind; /* sd_proxy_kind */ + char host[256]; + uint16_t port; +} host_leg; + typedef struct host_mount { struct host_mount *next; uint32_t num; @@ -53,8 +116,96 @@ typedef struct host_mount { wchar_t cache_dir[MAX_PATH]; /* per-source cache folder ("" when it could not be created) */ char remote[1024]; int started, stop_requested; + /* M2 */ + host_chain *chain; /* owned until sd_mount_create */ + char legacy_accepted[80]; /* security.legacy_accepted of the profile */ + int scope; /* SD_PROFILE_SCOPE_* of the profile: `list` scope */ + wchar_t save_comment[256]; /* Credential Manager comment, localized by the client */ + uint32_t hop_count; + /* M3 */ + uint32_t initiator_caps; /* SD_IPC_CAP_* of the initiator when it asked for the mount */ + int network_location; /* profile mount.network_location (03 §8.7) */ + int open_after, opened; /* mount.open_after_mount (or the mount open request): opened at the first MOUNTED */ + /* M3 (profile.test, status, unmount force) */ + int test_only; /* profile.test: not in `list`, the snapshot, Explorer or mount.state */ + int stop_force; /* unmount {force}: the write-behind drain gets no budget */ + uint64_t defer_cid; /* profile.test: the client waiting for the reply (0 = none) */ + int64_t defer_rid; /* its request id */ + uint32_t nlegs; /* proxy legs of the chain, for status proxy_legs */ + host_leg legs[SD_MOUNT_MAX_HOPS]; + /* M5 hostkey add (test = 2): the request fields the reply needs (hk = 1) */ + int hk; + char hk_lookup[300]; + char hk_type[64]; + char hk_expect[96]; + /* M5 mount.state reason (round 19): the error code of the offline period the connection is coming back from */ + char drop_code[32]; } host_mount; +/* ---- M3 constants (03 §2.5, §3.3, 1.7.4) ---- */ +#define PROMPT_QUEUE_MAX 16u +#define PROMPT_DEADLINE_MS 120000u /* user-initiated prompts */ +#define PROMPT_RECONNECT_MS 600000u /* prompts raised by a reconnect */ +#define UI_LAUNCH_INTERVAL_MS 30000u /* at most one prompt-UI launch per 30 s */ +#define WARN_RING 32u /* recent warnings kept for `list` */ +#define EVENT_RING 200u /* recent events kept for `events.recent` (03 §2.4, 7.7) */ + +/* Feature names of the hello reply (03 §7, 2.8): only what this build implements. */ +static const char *const k_agent_features[] = { + "status", "config.reload", "log.tail", "selftest", "prompt.list", "prompt.queued", + "refresh", "cache.clear", "profile.test", "mount.attach_info", "status.stats", "status.proxy_legs", + "config.changed", "agent.stopping", "update", "update.check", "update.apply", "diag.collect", "events.recent", NULL +}; + +#ifdef SD_HOST_TEST_HOOKS +/* tests: prompt deadlines and queue size can be shortened through the environment */ +static uint32_t env_u32(const wchar_t *name, uint32_t def) +{ + wchar_t v[16]; + uint32_t n = 0; + const wchar_t *p; + if (GetEnvironmentVariableW(name, v, SD_COUNTOF(v)) - 1u >= SD_COUNTOF(v) - 1u) + return def; + for (p = v; *p >= L'0' && *p <= L'9'; p++) + n = n * 10u + (uint32_t)(*p - L'0'); + return n ? n : def; +} +#endif + +typedef struct host_warn { char code[40]; char mount_id[16]; } host_warn; + +/* One event of the agent's history (03 §2.4 events.recent): the mount.state, warning and hostkeys.announced events + the clients get, as short records. Oldest records are dropped first; seq is never reused in this process. */ +typedef struct host_event { + uint64_t seq; + int64_t time; /* unix seconds */ + char event[24]; + char mount_id[16]; + char state[24]; /* mount.state: the new state */ + char code[40]; /* error or warning code; "" when none */ +} host_event; + +/* M3 prompt record (03 §3.2): from the sd_mount prompt until its prompt.closed. */ +typedef struct host_prompt { + struct host_prompt *next; + sd_mount *m; + uint32_t prompt_id; /* sd_mount's id */ + char pid[32]; /* "p--" */ + char mount_id[16]; + char profile_id[33]; + char kind[24]; + uint64_t initiator; /* client that asked for the mount when raised (0 = none) */ + int queued; /* 1: not delivered to any client yet */ + int expire; /* deadline passed or evicted: rejected by the next flush */ + int answering; /* a reject is in flight */ + uint64_t deadline; /* sd_tick_ms() */ + uint64_t seq; /* queue order */ + const char *reason; /* answered | cancelled | timeout | superseded; NULL = cancelled */ + const char *answered_by; /* "cli" | "ui" once a client answered */ + char *body; /* the prompt.request data object without its closing brace */ + size_t body_len; +} host_prompt; + static struct { sd_mutex lock; host_mount *mounts; @@ -67,8 +218,35 @@ static struct { volatile LONG stop; uint64_t last_busy; sd_settings settings; /* settings.ini, read at start */ + /* M2 */ + host_announce *announces; + uint32_t next_announce; + wchar_t user_kh[MAX_PATH]; + /* M3 */ + host_prompt *prompts; /* open and queued prompts */ + uint64_t prompt_seq; + uint32_t prompt_queue_max; /* PROMPT_QUEUE_MAX (test hook: SHELLDRIVE_TEST_PROMPT_QUEUE_MAX) */ + uint64_t prompt_ms, reconnect_prompt_ms; + int no_route; /* test hook: prompts are never routed (queue tests) */ + uint64_t last_ui_launch; /* tick of the last prompt-UI launch attempt; 0 = never */ + int ui_launch; /* 0 in test builds unless SHELLDRIVE_TEST_UI_LAUNCH=1: tests never start a UI */ + host_warn warns[WARN_RING]; + uint32_t warn_next, warn_count; + /* event history: its own lock, a leaf (taken while g.lock may be held, never the other way round) */ + sd_mutex evlock; + host_event events[EVENT_RING]; + uint32_t event_next, event_count; + uint64_t event_seq; + uint64_t boot_tick; /* log.tail cursor boot id */ + /* M3 agent.stopping reason: "request", "event" or "idle" */ + const char *stop_reason; + uint32_t kh_warned; /* round 19: live sources (bit per source) already warned hostkey_source_insecure */ } g; +/* forward declarations: the prompt broker and the warning ring are defined further down */ +static void prompt_drop_mount(sd_mount *m); +static void remember_warning(const char *code, const char *mount_id); + /* ---- control queue ---- */ static void post(void *ctx, void (*fn)(void *), void *arg) @@ -113,6 +291,24 @@ static void scopy(char *dst, size_t cap, const char *src) dst[i] = 0; } +/* Appends one event to the history (03 §2.4 events.recent). Callers may hold g.lock: evlock is a leaf. */ +static void record_event(const char *event, const char *mount_id, const char *state, const char *code) +{ + host_event *e; + sd_mutex_lock(&g.evlock); + e = &g.events[g.event_next]; + e->seq = ++g.event_seq; + e->time = sd_unix_time_now(); + scopy(e->event, sizeof(e->event), event); + scopy(e->mount_id, sizeof(e->mount_id), mount_id); + scopy(e->state, sizeof(e->state), state); + scopy(e->code, sizeof(e->code), code); + g.event_next = (g.event_next + 1u) % EVENT_RING; + if (g.event_count < EVENT_RING) + g.event_count++; + sd_mutex_unlock(&g.evlock); +} + static int wide_from_utf8(const char *s, wchar_t *out, size_t cch) { size_t got = 0; @@ -140,6 +336,17 @@ static void jw_letter(sd_json_writer *jw, wchar_t l) sd_jw_cstr(jw, t); } +static void free_chain(host_chain *c) +{ + uint32_t i; + if (c == NULL) + return; + for (i = 0; i < SD_MOUNT_MAX_HOPS; i++) + sd_secbuf_free(c->key[i]); + sd_secure_zero(c, sizeof(*c)); + sd_free(c); +} + static host_mount *find_by_mount(sd_mount *m) { host_mount *h; @@ -188,6 +395,13 @@ static void write_conn_json(sd_json_writer *jw, host_mount *h) if (c.reason != SD_OK) { sd_jw_key(jw, "reason"); sd_jw_cstr(jw, sd_err_name(c.reason)); } + if (c.pool || c.pool_wanted > 1) { + /* pool size and the requested one (03 §2.4 status detail; the 2FA rule shows as pool < wanted) */ + sd_jw_key(jw, "pool"); sd_jw_int(jw, c.pool); + if (c.pool_wanted > 1) { + sd_jw_key(jw, "pool_wanted"); sd_jw_int(jw, c.pool_wanted); + } + } sd_jw_obj_end(jw); sd_jw_key(jw, "write_failed"); sd_jw_int(jw, c.write_failed); if (c.write_failed) { @@ -195,6 +409,64 @@ static void write_conn_json(sd_json_writer *jw, host_mount *h) } } +/* `list` scope (03 §2.3): user (the user's own profile), machine_template (scope = users, read-only templates + from sd_config) or global (scope = service, the Global drives group). */ +static const char *scope_name(int scope) +{ + switch (scope) { + case SD_PROFILE_SCOPE_USERS: return "machine_template"; + case SD_PROFILE_SCOPE_SERVICE: return "global"; + default: return "user"; + } +} + +/* Names of the store kinds of a host key (03 §2.4 error.store_kind, 5.4): machine, user, openssh, putty. */ +static const char *store_kind_name(sd_kh_store_kind k) +{ + switch (k) { + case SD_KH_STORE_MACHINE: return "machine"; + case SD_KH_STORE_USER: return "user"; + case SD_KH_SRC_OPENSSH: return "openssh"; + case SD_KH_SRC_PUTTY: return "putty"; + } + return "unknown"; +} + +/* A comma list as a JSON array of its non-empty items (stored and offered key types). */ +static void write_csv_array(sd_json_writer *jw, const char *key, const char *csv) +{ + char buf[256]; + char *t, *ctx = NULL; + sd_jw_key(jw, key); + sd_jw_arr_begin(jw); + scopy(buf, sizeof(buf), csv ? csv : ""); + for (t = strtok_s(buf, ",", &ctx); t; t = strtok_s(NULL, ",", &ctx)) + if (t[0]) sd_jw_cstr(jw, t); + sd_jw_arr_end(jw); +} + +/* The Security state of the current connection (03 §7.6, 2.4), not the warnings ring: legacy (the flag), + legacy_algorithms ([{category, name}]) and terrapin_exposed. g.lock held. */ +static void write_security_json(sd_json_writer *jw, host_mount *h) +{ + sd_mount_security sec; + uint32_t i; + memset(&sec, 0, sizeof(sec)); + if (h->m) + sd_mount_get_security(h->m, &sec); + sd_jw_key(jw, "legacy"); sd_jw_bool(jw, sec.legacy); + sd_jw_key(jw, "legacy_algorithms"); + sd_jw_arr_begin(jw); + for (i = 0; i < sec.legacy_count; i++) { + sd_jw_obj_begin(jw); + sd_jw_key(jw, "category"); sd_jw_cstr(jw, sec.legacy_algs[i].category); + sd_jw_key(jw, "name"); sd_jw_cstr(jw, sec.legacy_algs[i].name); + sd_jw_obj_end(jw); + } + sd_jw_arr_end(jw); + sd_jw_key(jw, "terrapin_exposed"); sd_jw_bool(jw, sec.terrapin_exposed); +} + static void write_mount_json(sd_json_writer *jw, host_mount *h) { sd_jw_obj_begin(jw); @@ -209,6 +481,8 @@ static void write_mount_json(sd_json_writer *jw, host_mount *h) sd_jw_key(jw, "port"); sd_jw_int(jw, h->port); sd_jw_key(jw, "user"); sd_jw_cstr(jw, h->user); sd_jw_key(jw, "read_only"); sd_jw_bool(jw, h->read_only); + sd_jw_key(jw, "scope"); sd_jw_cstr(jw, scope_name(h->scope)); + write_security_json(jw, h); write_conn_json(jw, h); sd_jw_obj_end(jw); } @@ -217,6 +491,9 @@ static void send_state_event(host_mount *h, sd_mount_state st, sd_err why) { sd_wr d; sd_json_writer jw; + const char *ecode = NULL; /* the error code of a failed state, for the event history */ + if (h->test_only) /* profile.test is not a mount for the clients (M3) */ + return; sd_wr_init(&d, 64 * 1024); sd_jw_init(&jw, &d); sd_jw_obj_begin(&jw); @@ -229,26 +506,49 @@ static void send_state_event(host_mount *h, sd_mount_state st, sd_err why) sd_mount_get_failure(h->m, &f); if (f.err == SD_OK) f.err = why; code = final_code(h, &f); + ecode = code; sd_jw_key(&jw, "error"); sd_jw_obj_begin(&jw); sd_jw_key(&jw, "code"); sd_jw_cstr(&jw, code); sd_jw_key(&jw, "detail"); sd_jw_cstr(&jw, f.detail); sd_jw_key(&jw, "exit_code"); sd_jw_int(&jw, sd_ipc_exit_code_for_code(code)); - if (f.err == SD_E_HOSTKEY_MISMATCH) { + if (f.err == SD_E_HOSTKEY_MISMATCH || f.err == SD_E_HOSTKEY_TYPE_UNAVAILABLE) { sd_jw_key(&jw, "host"); sd_jw_cstr(&jw, h->host); + if (f.lookup_name[0]) { sd_jw_key(&jw, "lookup_name"); sd_jw_cstr(&jw, f.lookup_name); } + } + if (f.err == SD_E_HOSTKEY_MISMATCH) { sd_jw_key(&jw, "stored_type"); sd_jw_cstr(&jw, f.stored_type); sd_jw_key(&jw, "stored_fp"); sd_jw_cstr(&jw, f.stored_fp); sd_jw_key(&jw, "offered_type"); sd_jw_cstr(&jw, f.offered_type); sd_jw_key(&jw, "offered_fp"); sd_jw_cstr(&jw, f.offered_fp); sd_jw_key(&jw, "store_path"); jw_wstr(&jw, f.store_path); sd_jw_key(&jw, "store_line"); sd_jw_int(&jw, f.store_line); + if (f.has_store_kind) { sd_jw_key(&jw, "store_kind"); sd_jw_cstr(&jw, store_kind_name(f.store_kind)); } + } else if (f.err == SD_E_HOSTKEY_TYPE_UNAVAILABLE) { + /* the stored types (comma list, also in stored_type for clients that show one string) and the server's + offered algorithms; the offered key and its fingerprint when the server presented one (03 §5.2) */ + sd_jw_key(&jw, "stored_type"); sd_jw_cstr(&jw, f.stored_types); + write_csv_array(&jw, "stored_types", f.stored_types); + write_csv_array(&jw, "offered_types", f.offered_types); + sd_jw_key(&jw, "offered_type"); sd_jw_cstr(&jw, f.offered_type); + sd_jw_key(&jw, "offered_fp"); sd_jw_cstr(&jw, f.offered_fp); } sd_jw_obj_end(&jw); } + { + /* reason (03 §2.4): the error that took the mount offline, while it reconnects; an additive member */ + char reason[32]; + if (agent_reason_on_state(h->drop_code, sizeof(h->drop_code), sd_mount_state_name(st), ecode, reason, + sizeof(reason))) { + sd_jw_key(&jw, "reason"); + sd_jw_cstr(&jw, reason); + } + } write_conn_json(&jw, h); sd_jw_obj_end(&jw); if (sd_jw_finish(&jw) == SD_OK) broadcast_data("mount", "mount.state", &d); + record_event("mount.state", h->id, sd_mount_state_name(st), ecode); sd_wr_free(&d); } @@ -272,6 +572,8 @@ static void send_warning(host_mount *h, const char *code, const wchar_t *path, U if (detail) { sd_jw_key(&jw, "detail"); sd_jw_cstr(&jw, detail); } sd_jw_obj_end(&jw); sd_jw_obj_end(&jw); + remember_warning(code, h->id); + record_event("warning", h->id, NULL, code); if (sd_jw_finish(&jw) == SD_OK) { broadcast_data("mount", "warning", &d); broadcast_data("warning", "warning", &d); @@ -279,14 +581,126 @@ static void send_warning(host_mount *h, const char *code, const wchar_t *path, U sd_wr_free(&d); } +/* ---- M3 state snapshot and Explorer data (03 §8.2.1, §8.3, §8.7) ---- */ + +/* The producer copies the URL during the call; this table keeps the strings alive for that call. g.lock held. */ +static char g_snap_url[SD_STATE_MAX_ENTRIES][1024]; + +static uint32_t snap_backend(const host_mount *h) +{ + const char *n = h->m ? sd_mount_backend_kind_name(sd_mount_backend_kind_used(h->m)) : NULL; + if (n && strcmp(n, "webdav") == 0) + return SD_STATE_B_WEBDAV; + if (n && strcmp(n, "dokany") == 0) + return SD_STATE_B_DOKANY; + return SD_STATE_B_UNKNOWN; +} + +/* The drive is mapped: mounted, offline (the letter stays) or waiting for credentials. */ +static int mount_present(const host_mount *h) +{ + return h->state == SD_MOUNT_MOUNTED || h->state == SD_MOUNT_OFFLINE || h->state == SD_MOUNT_AUTH_REQUIRED; +} + +/* sftp://user@host:port/path (IPv6 literals in brackets). The path only: never credentials (03 §8.2.1). */ +static void mount_url(const host_mount *h, char *out, size_t cap) +{ + if (strchr(h->host, ':') != NULL) + _snprintf_s(out, cap, _TRUNCATE, "sftp://%s@[%s]:%u%s", h->user, h->host, (unsigned)h->port, h->remote); + else + _snprintf_s(out, cap, _TRUNCATE, "sftp://%s@%s:%u%s", h->user, h->host, (unsigned)h->port, h->remote); +} + +/* Publishes every mount into the snapshot section. g.lock held. */ +static void state_publish_locked(uint32_t flags) +{ + host_state_src src[SD_STATE_MAX_ENTRIES]; + uint32_t n = 0; + const host_mount *h; + for (h = g.mounts; h && n < SD_STATE_MAX_ENTRIES; h = h->next) { + host_state_src *s; + if (h->test_only) + continue; + s = &src[n]; + memset(s, 0, sizeof(*s)); + s->mount_num = h->num; + s->mount_id = h->id; + s->profile_id = h->profile_id; + s->profile_name = h->profile_name; + mount_url(h, g_snap_url[n], sizeof(g_snap_url[n])); + s->remote_url = g_snap_url[n]; + s->label = h->label; + s->letter = h->letter; + s->state = SD_STATE_S_CONNECTING + (uint32_t)h->state; /* sd_mount_state has the order of SD_STATE_S_* */ + s->backend = snap_backend(h); + s->read_only = h->read_only; + s->network_location = h->network_location; + n++; + } + host_state_publish(src, n, flags); +} + +static void work_explorer(void *arg) +{ + host_explorer_mount *x = (host_explorer_mount *)arg; + explorer_apply(x); + sd_free(x); +} + +/* Copies the Explorer data of a mount and runs the change on the control thread, without g.lock. */ +static void explorer_post_locked(const host_mount *h) +{ + host_explorer_mount *x = (host_explorer_mount *)sd_calloc(1, sizeof(*x)); + if (x == NULL) + return; + x->letter = h->letter; + x->present = mount_present(h); + x->network_location = h->network_location; + scopy(x->profile_id, sizeof(x->profile_id), h->profile_id); + scopy(x->profile_name, sizeof(x->profile_name), h->profile_name); + post(NULL, work_explorer, x); +} + +/* A mount was added, changed or removed (h is no longer linked when it is removed). g.lock held. */ +static void mount_changed_locked(const host_mount *h) +{ + if (h->test_only) + return; + state_publish_locked(0); + explorer_post_locked(h); +} + /* ---- sd_mount callbacks (control thread) ---- */ +/* The recent warnings of a mount that is gone leave the ring, the others keep their order (g.lock held). The + ring is rewritten from slot 0 so that warn_next = warn_count again. */ +static void forget_warnings_locked(const char *mount_id) +{ + host_warn kept[WARN_RING]; + uint32_t i, start, n = 0; + start = (g.warn_next + WARN_RING - g.warn_count) % WARN_RING; + for (i = 0; i < g.warn_count; i++) { + const host_warn *w = &g.warns[(start + i) % WARN_RING]; + if (strcmp(w->mount_id, mount_id) != 0) + kept[n++] = *w; + } + memcpy(g.warns, kept, n * sizeof(kept[0])); + g.warn_count = n; + g.warn_next = n % WARN_RING; +} + static void destroy_record_locked(host_mount *h) { host_mount **pp; for (pp = &g.mounts; *pp; pp = &(*pp)->next) if (*pp == h) { *pp = h->next; break; } - if (h->m) sd_mount_destroy(h->m); + forget_warnings_locked(h->id); + mount_changed_locked(h); + if (h->m) { + prompt_drop_mount(h->m); + sd_mount_destroy(h->m); + } + free_chain(h->chain); sd_free(h); } @@ -302,6 +716,143 @@ static void work_reap(void *arg) sd_mutex_unlock(&g.lock); } +/* profile.test (M3): the deferred reply of the test mount, sent once when the mount is final. g.lock held. */ +static void reply_profile_test(host_mount *h, sd_mount *m, sd_err why) +{ + sd_mount_probe p; + sd_mount_failure f; + sd_err e; + if (h->defer_cid == 0 || g.srv == NULL) + return; + if (why == SD_OK && sd_mount_get_probe(m, &p)) { + sd_wr d; + sd_json_writer jw; + sd_wr_init(&d, 8 * 1024); + sd_jw_init(&jw, &d); + sd_jw_obj_begin(&jw); + sd_jw_key(&jw, "profile"); sd_jw_cstr(&jw, h->profile_name); + sd_jw_key(&jw, "host"); sd_jw_cstr(&jw, h->host); + sd_jw_key(&jw, "port"); sd_jw_int(&jw, h->port); + sd_jw_key(&jw, "user"); sd_jw_cstr(&jw, h->user); + sd_jw_key(&jw, "remote_path"); sd_jw_cstr(&jw, p.remote_real); + sd_jw_key(&jw, "hostkey_fingerprint"); sd_jw_cstr(&jw, p.hostkey_fp); + sd_jw_key(&jw, "server_version"); sd_jw_cstr(&jw, p.server_version); + sd_jw_key(&jw, "algorithms"); + sd_jw_obj_begin(&jw); + sd_jw_key(&jw, "kex"); sd_jw_cstr(&jw, p.kex); + sd_jw_key(&jw, "hostkey"); sd_jw_cstr(&jw, p.hostkey); + sd_jw_key(&jw, "cipher_c2s"); sd_jw_cstr(&jw, p.cipher_c2s); + sd_jw_key(&jw, "cipher_s2c"); sd_jw_cstr(&jw, p.cipher_s2c); + sd_jw_key(&jw, "mac_c2s"); sd_jw_cstr(&jw, p.mac_c2s); + sd_jw_key(&jw, "mac_s2c"); sd_jw_cstr(&jw, p.mac_s2c); + sd_jw_obj_end(&jw); + sd_jw_key(&jw, "legacy"); sd_jw_bool(&jw, p.legacy); + sd_jw_key(&jw, "hops"); sd_jw_int(&jw, h->hop_count ? h->hop_count : 1); + sd_jw_obj_end(&jw); + if (sd_jw_finish(&jw) == SD_OK) + e = sd_ipc_reply_deferred(g.srv, h->defer_cid, h->defer_rid, SD_OK, NULL, NULL, (const char *)d.p, d.len); + else + e = sd_ipc_reply_deferred(g.srv, h->defer_cid, h->defer_rid, SD_E_INTERNAL, NULL, "reply too large", NULL, 0); + sd_wr_free(&d); + } else { + const char *code; + memset(&f, 0, sizeof(f)); + sd_mount_get_failure(m, &f); + if (why == SD_OK) { + /* ended without a result (torn down before READY): the request is answered, not left hanging */ + code = "cancelled"; + f.err = SD_E_CANCELLED; + if (f.detail[0] == 0) + scopy(f.detail, sizeof(f.detail), "the test ended before the connection was ready"); + } else { + if (f.err == SD_OK) f.err = why; + code = final_code(h, &f); + if (f.detail[0] == 0) + scopy(f.detail, sizeof(f.detail), sd_err_name(f.err)); + } + e = sd_ipc_reply_deferred(g.srv, h->defer_cid, h->defer_rid, f.err, code, f.detail, NULL, 0); + } + if (e != SD_OK && e != SD_E_NOT_FOUND) + SD_LOGW("agent", "%s: profile.test reply not sent (%s)", h->id, sd_err_name(e)); + h->defer_cid = 0; +} + +/* ---- M3 profile tuning, open after mount, autostart (03 §3.4.1) ---- */ + +/* The vfs and session options of the profile. */ +static void tuning_of(const sd_profile *p, sd_mount_tuning *t) +{ + memset(t, 0, sizeof(*t)); + t->attr_ttl_ms = p->attr_ttl_ms; + t->dir_ttl_ms = p->dir_ttl_ms; + t->neg_ttl_ms = p->neg_ttl_ms; + t->open_revalidate_ms = p->open_revalidate_ms; + t->max_readahead = p->max_readahead; + t->umask = p->umask; + t->hide_dotfiles = p->hide_dotfiles; + t->map_readonly = p->map_readonly; + t->show_special = p->show_special; + t->case_sensitive = p->case_sensitive; + t->remote_case = p->remote_case; + t->fsync_on_flush = p->fsync_on_flush; + t->shell_active_show = !sd_profile_block_shell_active(p); + t->identity_probe_exec = p->identity_probe_exec; + (void)wide_from_utf8(p->fs_name, t->fs_name, SD_COUNTOF(t->fs_name)); +} + +/* mount.open_after_mount: Explorer opens the drive root. Test builds never open a window: the root is appended + to the file named by SHELLDRIVE_TEST_OPEN_FILE (nothing is written when the variable is not set). */ +static void shell_explore(const wchar_t *root) +{ +#ifdef SD_HOST_TEST_HOOKS + { + wchar_t file[MAX_PATH]; + if (GetEnvironmentVariableW(L"SHELLDRIVE_TEST_OPEN_FILE", file, MAX_PATH) > 0 && wcslen(file) < MAX_PATH - 1) { + HANDLE f = CreateFileW(file, GENERIC_WRITE, FILE_SHARE_READ, NULL, OPEN_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL); + if (f != INVALID_HANDLE_VALUE) { + char line[4]; + DWORD put = 0; + line[0] = (char)root[0]; + line[1] = ':'; + line[2] = '\\'; + line[3] = '\n'; + SetFilePointer(f, 0, NULL, FILE_END); + (void)WriteFile(f, line, sizeof(line), &put, NULL); + CloseHandle(f); + } + } + return; + } +#else + if ((INT_PTR)ShellExecuteW(NULL, L"explore", root, NULL, NULL, SW_SHOWNORMAL) <= 32) + SD_LOGW("agent", "explorer did not open the drive (error %u)", (unsigned)GetLastError()); +#endif +} + +/* Posted by on_state at the first MOUNTED of a user-initiated mount (never on a reconnect). */ +static void work_open(void *arg) +{ + uint32_t num = (uint32_t)(uintptr_t)arg; + host_mount *h; + wchar_t root[4]; + root[0] = 0; + sd_mutex_lock(&g.lock); + for (h = g.mounts; h; h = h->next) { + if (h->num == num && h->state == SD_MOUNT_MOUNTED && !h->test_only) { + root[0] = h->letter; + root[1] = L':'; + root[2] = L'\\'; + root[3] = 0; + break; + } + } + sd_mutex_unlock(&g.lock); + if (root[0]) + shell_explore(root); +} + +static void reply_hostkey_add(host_mount *h, sd_mount *m, sd_err why); + static void on_state(void *ctx, sd_mount *m, sd_mount_state st, sd_err why) { host_mount *h; @@ -310,7 +861,18 @@ static void on_state(void *ctx, sd_mount *m, sd_mount_state st, sd_err why) h = find_by_mount(m); if (h) { h->state = st; + if (st == SD_MOUNT_MOUNTED && h->open_after && !h->opened) { + h->opened = 1; + post(NULL, work_open, (void *)(uintptr_t)h->num); + } send_state_event(h, st, why); + mount_changed_locked(h); + if (h->test_only && sd_mount_is_final(m)) { + if (h->hk) + reply_hostkey_add(h, m, why); + else + reply_profile_test(h, m, why); + } if (sd_mount_is_final(m)) post(NULL, work_reap, h); } @@ -353,6 +915,190 @@ static void on_notice(void *ctx, sd_mount *m, const char *code, const char *deta sd_mutex_unlock(&g.lock); } +/* M2 warnings (03 §2.4, §7.6): legacy_algorithms carries "algorithms": [...]. */ +static void on_warning(void *ctx, sd_mount *m, const char *code, uint32_t hop, const char *detail) +{ + host_mount *h; + SD_UNUSED(ctx); + sd_mutex_lock(&g.lock); + h = find_by_mount(m); + if (h && code) { + sd_wr d; + sd_json_writer jw; + int suppress = 0; + if (strcmp(code, "legacy_algorithms") == 0 && detail && h->legacy_accepted[0]) { + const char *names[16]; + char buf[256], hash[80]; + uint32_t n = 0; + char *t, *ctxs = NULL; + scopy(buf, sizeof(buf), detail); + for (t = strtok_s(buf, ",", &ctxs); t && n < 16; t = strtok_s(NULL, ",", &ctxs)) names[n++] = t; + sd_legacy_set_hash(names, n, hash); + suppress = strcmp(hash, h->legacy_accepted) == 0; /* "Don't warn again for this server" (7.6) */ + } + if (suppress) { + SD_LOGI("agent", "%s: legacy algorithms %s accepted by the profile", h->id, detail); + } else { + SD_LOGW("agent", "%s: warning %s (hop %u): %s", h->id, code, (unsigned)hop, detail ? detail : ""); + remember_warning(code, h->id); + record_event("warning", h->id, NULL, code); + sd_wr_init(&d, 16 * 1024); + sd_jw_init(&jw, &d); + sd_jw_obj_begin(&jw); + sd_jw_key(&jw, "code"); sd_jw_cstr(&jw, code); + sd_jw_key(&jw, "mount_id"); sd_jw_cstr(&jw, h->id); + sd_jw_key(&jw, "data"); + sd_jw_obj_begin(&jw); + sd_jw_key(&jw, "target"); jw_letter(&jw, h->letter); + sd_jw_key(&jw, "host"); sd_jw_cstr(&jw, h->host); + sd_jw_key(&jw, "hop"); sd_jw_int(&jw, (int64_t)hop + 1); + sd_jw_key(&jw, "hops"); sd_jw_int(&jw, h->hop_count ? h->hop_count : 1); + if (detail) { sd_jw_key(&jw, "detail"); sd_jw_cstr(&jw, detail); } + if (strcmp(code, "legacy_algorithms") == 0 && detail) { + char buf[256]; + char *t, *ctxs = NULL; + scopy(buf, sizeof(buf), detail); + sd_jw_key(&jw, "algorithms"); + sd_jw_arr_begin(&jw); + for (t = strtok_s(buf, ",", &ctxs); t; t = strtok_s(NULL, ",", &ctxs)) sd_jw_cstr(&jw, t); + sd_jw_arr_end(&jw); + } + sd_jw_obj_end(&jw); + sd_jw_obj_end(&jw); + if (sd_jw_finish(&jw) == SD_OK) { + broadcast_data("mount", "warning", &d); + broadcast_data("warning", "warning", &d); + } + sd_wr_free(&d); + } + } + sd_mutex_unlock(&g.lock); +} + +/* Saved secrets (03 §4): user scope; sd_mount decides when they may be used (TM-R05). */ +static int secret_get(void *ctx, sd_mount *m, int kind, const char *target, char *out, size_t cap) +{ + sd_secbuf *b = NULL; + int ok = 0; + SD_UNUSED(ctx); + SD_UNUSED(m); + if (sd_secret_get(SD_SECRET_USER, (sd_secret_kind)kind, target, &b) == SD_OK) { + if (b->len + 1 <= cap) { + memcpy(out, b->p, b->len); + out[b->len] = 0; + ok = 1; + } + sd_secbuf_free(b); + } + return ok; +} + +static void secret_put(void *ctx, sd_mount *m, int kind, const char *target, const char *value) +{ + host_mount *h; + wchar_t comment[256]; + sd_err e; + SD_UNUSED(ctx); + comment[0] = 0; + sd_mutex_lock(&g.lock); + h = find_by_mount(m); + if (h) memcpy(comment, h->save_comment, sizeof(comment)); + sd_mutex_unlock(&g.lock); + e = sd_secret_put(SD_SECRET_USER, (sd_secret_kind)kind, target, comment[0] ? comment : NULL, value, strlen(value)); + if (e != SD_OK) + SD_LOGW("agent", "cannot save the secret (%s)", sd_err_name(e)); +} + +static void write_keys_json(sd_json_writer *jw, const char *key, const sd_mount_hostkey *k, uint32_t n) +{ + uint32_t i; + sd_jw_key(jw, key); + sd_jw_arr_begin(jw); + for (i = 0; i < n; i++) { + sd_jw_obj_begin(jw); + sd_jw_key(jw, "type"); sd_jw_cstr(jw, k[i].type); + sd_jw_key(jw, "fingerprint"); sd_jw_cstr(jw, k[i].fp_sha256); + sd_jw_obj_end(jw); + } + sd_jw_arr_end(jw); +} + +static void write_announce_json(sd_json_writer *jw, const host_announce *x) +{ + sd_jw_obj_begin(jw); + sd_jw_key(jw, "id"); sd_jw_cstr(jw, x->id); + sd_jw_key(jw, "mount_id"); sd_jw_cstr(jw, x->mount_id); + sd_jw_key(jw, "host"); sd_jw_cstr(jw, x->a.host); + sd_jw_key(jw, "lookup_name"); sd_jw_cstr(jw, x->a.lookup_name); + sd_jw_key(jw, "hop"); sd_jw_int(jw, (int64_t)x->a.hop_index + 1); + write_keys_json(jw, "add", x->a.add, x->a.add_count); + write_keys_json(jw, "remove", x->a.remove, x->a.remove_count); + sd_jw_obj_end(jw); +} + +/* UpdateHostKeys (5.6): ask -> kept for review and announced; yes -> already applied, announced too. */ +static void on_hostkeys_announced(void *ctx, sd_mount *m, const sd_mount_announcement *a, int applied, sd_err err) +{ + host_mount *h; + SD_UNUSED(ctx); + sd_mutex_lock(&g.lock); + h = find_by_mount(m); + if (h) { + sd_wr d; + sd_json_writer jw; + sd_wr_init(&d, 64 * 1024); + sd_jw_init(&jw, &d); + if (applied) { + sd_jw_obj_begin(&jw); + sd_jw_key(&jw, "mount_id"); sd_jw_cstr(&jw, h->id); + sd_jw_key(&jw, "host"); sd_jw_cstr(&jw, a->host); + sd_jw_key(&jw, "applied"); sd_jw_bool(&jw, 1); + sd_jw_key(&jw, "result"); sd_jw_cstr(&jw, sd_err_name(err)); + write_keys_json(&jw, "add", a->add, a->add_count); + sd_jw_obj_end(&jw); + } else { + host_announce *x = (host_announce *)sd_calloc(1, sizeof(*x)), *o; + char tmp[12]; + size_t n = 0, i = 0; + uint32_t v; + if (x == NULL) { + sd_mutex_unlock(&g.lock); + sd_wr_free(&d); + return; + } + /* once per host and announced key set: a newer announcement for the same name replaces the old */ + for (o = g.announces; o; o = o->next) + if (strcmp(o->a.lookup_name, a->lookup_name) == 0) break; + if (o) { + memcpy(x->id, o->id, sizeof(x->id)); + o->a = *a; + scopy(o->mount_id, sizeof(o->mount_id), h->id); + sd_free(x); + x = o; + } else { + v = ++g.next_announce; + x->id[i++] = 'a'; x->id[i++] = '-'; + do { tmp[n++] = (char)('0' + v % 10u); v /= 10u; } while (v); + while (n) x->id[i++] = tmp[--n]; + x->id[i] = 0; + x->a = *a; + scopy(x->mount_id, sizeof(x->mount_id), h->id); + x->next = g.announces; + g.announces = x; + } + write_announce_json(&jw, x); + SD_LOGI("agent", "%s: %Hs offered new host keys (%s)", h->id, a->lookup_name, x->id); + } + record_event("hostkeys.announced", h->id, NULL, NULL); + if (sd_jw_finish(&jw) == SD_OK) { + broadcast_data("mount", "hostkeys.announced", &d); + broadcast_data("hostkeys", "hostkeys.announced", &d); + } + sd_wr_free(&d); + } + sd_mutex_unlock(&g.lock); +} + static int lookup_identity(void *ctx, sd_mount *m, const char *fp, sd_vfs_identity *out) { host_mount *h; @@ -423,25 +1169,242 @@ static int parse_prompt_id(const char *s, uint32_t *mnum, uint32_t *pid) return 1; } -static void on_prompt(void *ctx, sd_mount *m, const sd_mount_prompt *p) +/* ---- M3 prompt broker (03 §2.5, §3.2, §3.3) ---- + * Each prompt raised by sd_mount has a record in g.prompts until its prompt.closed. Routing: the + * initiator (when prompt-capable), then UI subscribers of "prompt" (cap prompts.ui); when nothing + * takes it the prompt is queued (PROMPT_QUEUE_MAX at most; the oldest queued prompt is evicted with + * reason superseded) and drained to the first UI that subscribes. Deadlines are checked by + * prompt_sweep once a second on the control thread. g.lock guards the list; sd_mount_answer is + * never called with g.lock held (it may run callbacks that take it), so rejects go through + * prompt_flush on the control queue. */ + +static void wr_raw(sd_wr *w, const char *s) +{ + sd_wr_bytes(w, s, strlen(s)); +} + +static void wr_u64(sd_wr *w, uint64_t v) +{ + char tmp[24]; + size_t n = 0; + do { tmp[n++] = (char)('0' + v % 10u); v /= 10u; } while (v); + while (n) { + char c = tmp[--n]; + sd_wr_bytes(w, &c, 1); + } +} + +/* prompt.request: the stored data object (without its closing brace) plus queued and the remaining + deadline in ms at send time (03 §2.5, 1.7.4). `out` must be initialized by the caller. */ +static sd_err prompt_request_event(const host_prompt *r, int queued, uint64_t now, sd_wr *out) +{ + sd_wr d; + sd_err e; + uint64_t remain = r->deadline > now ? r->deadline - now : 0; + sd_wr_init(&d, r->body_len + 128); + sd_wr_bytes(&d, r->body, r->body_len); + wr_raw(&d, queued ? ",\"queued\":true,\"deadline_ms\":" : ",\"queued\":false,\"deadline_ms\":"); + wr_u64(&d, remain); + wr_raw(&d, "}"); + e = sd_ipc_build_event("prompt.request", (const char *)d.p, d.len, out); + sd_wr_free(&d); + return e; +} + +static void prompt_free(host_prompt *r) +{ + if (r->body) { + sd_secure_zero(r->body, r->body_len); + sd_free(r->body); + } + sd_free(r); +} + +static void prompt_unlink(host_prompt *r) +{ + host_prompt **pp; + for (pp = &g.prompts; *pp; pp = &(*pp)->next) + if (*pp == r) { + *pp = r->next; + return; + } +} + +/* g.lock held. Called before a mount is destroyed: its prompts are gone with it. */ +static void prompt_drop_mount(sd_mount *m) +{ + host_prompt *r, *next; + for (r = g.prompts; r; r = next) { + next = r->next; + if (r->m == m) { + prompt_unlink(r); + prompt_free(r); + } + } +} + +/* prompt.closed to the initiator (once) and to UI subscribers of "prompt" that are not the initiator. */ +static void prompt_emit_closed(const host_prompt *r, const char *reason) { - host_mount *h; - char pid[32]; sd_wr d, w; sd_json_writer jw; - uint32_t i; - int delivered = 0; - SD_UNUSED(ctx); - sd_mutex_lock(&g.lock); - h = find_by_mount(m); - if (h == NULL) { - sd_mutex_unlock(&g.lock); - return; + sd_wr_init(&d, 1024); + sd_jw_init(&jw, &d); + sd_jw_obj_begin(&jw); + sd_jw_key(&jw, "prompt_id"); sd_jw_cstr(&jw, r->pid); + sd_jw_key(&jw, "mount_id"); sd_jw_cstr(&jw, r->mount_id); + sd_jw_key(&jw, "reason"); sd_jw_cstr(&jw, reason); + if (r->answered_by) { + sd_jw_key(&jw, "answered_by"); + sd_jw_cstr(&jw, r->answered_by); } - if (h->prompt_none) { - scopy(h->fail_code, sizeof(h->fail_code), p->kind == SD_MP_HOSTKEY ? "host_key_unknown" : "prompt_required"); - sd_mutex_unlock(&g.lock); - sd_mount_answer(m, p->id, SD_MA_REJECT, 0, NULL); + sd_jw_obj_end(&jw); + if (sd_jw_finish(&jw) == SD_OK) { + sd_wr_init(&w, 4096); + if (sd_ipc_build_event("prompt.closed", (const char *)d.p, d.len, &w) == SD_OK) { + uint64_t skip = 0; + if (r->initiator && sd_ipc_send_to(g.srv, r->initiator, (const char *)w.p, w.len) == SD_OK) + skip = r->initiator; + (void)sd_ipc_broadcast_count(g.srv, "prompt", (const char *)w.p, w.len, SD_IPC_CAP_PROMPTS_UI, skip, NULL); + } + sd_wr_free(&w); + } + sd_wr_free(&d); +} + +/* Starts the UI for prompts nobody took (03 §2.5 step 3). Rate-limited, skipped while a UI holds its + mutex, and a missing executable just leaves the prompts queued. Session lock state is not probed + in M3: a UI started while locked waits for its first subscription. g.lock held. */ +static void maybe_launch_ui(void) +{ + uint64_t now = sd_tick_ms(); + wchar_t dir[MAX_PATH], exe[MAX_PATH], cmd[MAX_PATH + 32]; + HANDLE ui = NULL; + STARTUPINFOW si; + PROCESS_INFORMATION pi; + DWORD n; + size_t i; + if (!g.ui_launch) + return; + if (g.last_ui_launch && now - g.last_ui_launch < UI_LAUNCH_INTERVAL_MS) + return; + g.last_ui_launch = now; + if (sd_ipc_object_open(SD_IPC_OBJ_UI_MUTEX, &ui) == SD_OK) { + CloseHandle(ui); /* a UI runs and is not subscribed yet: it will drain the queue */ + return; + } + n = GetModuleFileNameW(NULL, dir, MAX_PATH); + if (n == 0 || n >= MAX_PATH) + return; + for (i = wcslen(dir); i > 0 && dir[i - 1] != L'\\'; i--) + ; + if (i == 0 || i + 20 >= MAX_PATH) + return; + dir[i - 1] = 0; + wcscpy_s(exe, MAX_PATH, dir); + wcscat_s(exe, MAX_PATH, L"\\shelldrive-ui.exe"); + if (GetFileAttributesW(exe) == INVALID_FILE_ATTRIBUTES) + return; /* not installed (yet): the prompts stay queued */ + wcscpy_s(cmd, SD_COUNTOF(cmd), L"\""); + wcscat_s(cmd, SD_COUNTOF(cmd), exe); + wcscat_s(cmd, SD_COUNTOF(cmd), L"\" --prompt-host"); + memset(&si, 0, sizeof(si)); + memset(&pi, 0, sizeof(pi)); + si.cb = sizeof(si); + if (CreateProcessW(exe, cmd, NULL, NULL, FALSE, 0, NULL, dir, &si, &pi)) { + SD_LOGI("agent", "started the prompt UI"); + CloseHandle(pi.hThread); + CloseHandle(pi.hProcess); + } else { + SD_LOGW("agent", "cannot start the prompt UI (%u)", (unsigned)GetLastError()); + } +} + +/* Marks queued prompts that nobody can answer any more and rejects them on the control thread. */ +static void prompt_flush(void) +{ + for (;;) { + host_prompt *r; + sd_mount *m = NULL; + uint32_t id = 0; + sd_err e; + sd_mutex_lock(&g.lock); + for (r = g.prompts; r; r = r->next) + if (r->expire && !r->answering) + break; + if (r == NULL) { + sd_mutex_unlock(&g.lock); + return; + } + r->answering = 1; + m = r->m; + id = r->prompt_id; + sd_mutex_unlock(&g.lock); + e = sd_mount_answer(m, id, SD_MA_REJECT, 0, NULL); + if (e == SD_E_NOT_FOUND) { + /* the mount no longer holds it and no close event will come: drop the record */ + host_prompt *o; + sd_mutex_lock(&g.lock); + for (o = g.prompts; o; o = o->next) + if (o->m == m && o->prompt_id == id) { + prompt_unlink(o); + prompt_free(o); + break; + } + sd_mutex_unlock(&g.lock); + } + } +} + +static void work_flush(void *arg) +{ + SD_UNUSED(arg); + prompt_flush(); +} + +/* Once a second on the control thread: prompts past their deadline are rejected (reason timeout). */ +static void prompt_sweep(void) +{ + uint64_t now = sd_tick_ms(); + host_prompt *r; + int any = 0; + sd_mutex_lock(&g.lock); + for (r = g.prompts; r; r = r->next) { + if (!r->expire && now >= r->deadline) { + host_mount *h = find_by_mount(r->m); + r->expire = 1; + r->reason = "timeout"; + if (h) scopy(h->fail_code, sizeof(h->fail_code), "prompt_unavailable"); + any = 1; + } + } + sd_mutex_unlock(&g.lock); + if (any) + prompt_flush(); +} + +static void on_prompt(void *ctx, sd_mount *m, const sd_mount_prompt *p) +{ + host_mount *h; + char pid[32]; + sd_wr d, w; + sd_json_writer jw; + uint32_t i; + uint64_t now = sd_tick_ms(); + uint64_t to_initiator = 0; + uint32_t delivered = 0, queued_n = 0; + host_prompt *r = NULL; + SD_UNUSED(ctx); + sd_mutex_lock(&g.lock); + h = find_by_mount(m); + if (h == NULL) { + sd_mutex_unlock(&g.lock); + return; + } + if (h->prompt_none) { + scopy(h->fail_code, sizeof(h->fail_code), p->kind == SD_MP_HOSTKEY ? "host_key_unknown" : "prompt_required"); + sd_mutex_unlock(&g.lock); + sd_mount_answer(m, p->id, SD_MA_REJECT, 0, NULL); return; } prompt_id_str(h, p->id, pid); @@ -451,12 +1414,15 @@ static void on_prompt(void *ctx, sd_mount *m, const sd_mount_prompt *p) sd_jw_key(&jw, "prompt_id"); sd_jw_cstr(&jw, pid); sd_jw_key(&jw, "kind"); sd_jw_cstr(&jw, sd_mount_prompt_kind_name(p->kind)); sd_jw_key(&jw, "mount_id"); sd_jw_cstr(&jw, h->id); + sd_jw_key(&jw, "profile_id"); sd_jw_cstr(&jw, h->profile_id); sd_jw_key(&jw, "profile_name"); sd_jw_cstr(&jw, h->profile_name); sd_jw_key(&jw, "host"); sd_jw_cstr(&jw, p->host[0] ? p->host : h->host); sd_jw_key(&jw, "port"); sd_jw_int(&jw, p->port ? p->port : h->port); sd_jw_key(&jw, "user"); sd_jw_cstr(&jw, p->user[0] ? p->user : h->user); sd_jw_key(&jw, "hop"); sd_jw_int(&jw, (int64_t)p->hop_index + 1); - sd_jw_key(&jw, "hops"); sd_jw_int(&jw, 1); + sd_jw_key(&jw, "hops"); sd_jw_int(&jw, p->hop_count ? p->hop_count : 1); + if (p->via_host[0]) { sd_jw_key(&jw, "via_host"); sd_jw_cstr(&jw, p->via_host); } + sd_jw_key(&jw, "is_reconnect"); sd_jw_bool(&jw, p->is_reconnect); if (p->kind == SD_MP_HOSTKEY) { sd_jw_key(&jw, "key_type"); sd_jw_cstr(&jw, p->key_type); sd_jw_key(&jw, "key_bits"); sd_jw_int(&jw, p->key_bits); @@ -469,7 +1435,20 @@ static void on_prompt(void *ctx, sd_mount *m, const sd_mount_prompt *p) sd_jw_key(&jw, "key_path"); sd_jw_cstr(&jw, p->key_path); sd_jw_key(&jw, "key_fingerprint"); sd_jw_cstr(&jw, p->key_fp); } - if (p->kind == SD_MP_KBDINT) { + if (p->kind == SD_MP_PROXY_PASSWORD) { + sd_jw_key(&jw, "proxy_host"); sd_jw_cstr(&jw, p->proxy_host); + sd_jw_key(&jw, "proxy_port"); sd_jw_int(&jw, p->proxy_port); + } + if (p->kind == SD_MP_LEGACY_ACK) { + char buf[512]; + char *t, *ctxs = NULL; + scopy(buf, sizeof(buf), p->legacy_names); + sd_jw_key(&jw, "algorithms"); + sd_jw_arr_begin(&jw); + for (t = strtok_s(buf, ",", &ctxs); t; t = strtok_s(NULL, ",", &ctxs)) sd_jw_cstr(&jw, t); + sd_jw_arr_end(&jw); + } + if (p->kind == SD_MP_KBDINT || p->kind == SD_MP_PASSWORD_CHANGE) { sd_jw_key(&jw, "kbdint"); sd_jw_obj_begin(&jw); sd_jw_key(&jw, "name"); sd_jw_cstr(&jw, p->name); @@ -486,63 +1465,185 @@ static void on_prompt(void *ctx, sd_mount *m, const sd_mount_prompt *p) sd_jw_obj_end(&jw); } } - sd_jw_key(&jw, "can_save"); sd_jw_bool(&jw, 0); - sd_jw_key(&jw, "deadline_ms"); sd_jw_int(&jw, 120000); + sd_jw_key(&jw, "can_save"); sd_jw_bool(&jw, p->can_save); sd_jw_obj_end(&jw); - sd_wr_init(&w, SD_IPC_MAX_MESSAGE); - if (sd_jw_finish(&jw) == SD_OK && sd_ipc_build_event("prompt.request", (const char *)d.p, d.len, &w) == SD_OK) { - /* Routing (2.5): the initiating console client first, then UI subscribers of "prompt". */ - if (h->initiator && sd_ipc_send_to(g.srv, h->initiator, (const char *)w.p, w.len) == SD_OK) - delivered = 1; - sd_ipc_broadcast(g.srv, "prompt", (const char *)w.p, w.len); + if (sd_jw_finish(&jw) != SD_OK || d.len < 2) { + sd_wr_free(&d); + sd_mutex_unlock(&g.lock); + scopy(h->fail_code, sizeof(h->fail_code), "prompt_required"); + sd_mount_answer(m, p->id, SD_MA_REJECT, 0, NULL); + return; } - sd_wr_free(&w); - sd_wr_free(&d); - if (!delivered) { - /* No console client to answer and no UI launch/queue in M0: fail with prompt_required. */ - scopy(h->fail_code, sizeof(h->fail_code), p->kind == SD_MP_HOSTKEY ? "host_key_unknown" : "prompt_required"); + r = (host_prompt *)sd_calloc(1, sizeof(*r)); + if (r != NULL) + r->body = (char *)sd_alloc(d.len); + if (r == NULL || r->body == NULL) { + if (r) sd_free(r); + sd_wr_free(&d); sd_mutex_unlock(&g.lock); sd_mount_answer(m, p->id, SD_MA_REJECT, 0, NULL); return; } + /* stored without the closing brace: prompt_request_event appends queued and deadline_ms */ + memcpy(r->body, d.p, d.len - 1); + r->body_len = d.len - 1; + sd_wr_free(&d); + r->m = m; + r->prompt_id = p->id; + scopy(r->pid, sizeof(r->pid), pid); + scopy(r->mount_id, sizeof(r->mount_id), h->id); + scopy(r->profile_id, sizeof(r->profile_id), h->profile_id); + scopy(r->kind, sizeof(r->kind), sd_mount_prompt_kind_name(p->kind)); + r->initiator = h->initiator; + r->deadline = now + (p->is_reconnect ? g.reconnect_prompt_ms : g.prompt_ms); + r->seq = ++g.prompt_seq; + r->next = g.prompts; + g.prompts = r; + + /* step 1 and 2: the initiator (prompt-capable), then UI subscribers, the initiator excluded */ + if (!g.no_route) { + sd_wr_init(&w, SD_IPC_MAX_MESSAGE); + if (prompt_request_event(r, 0, now, &w) == SD_OK) { + if (h->initiator && (h->initiator_caps & (SD_IPC_CAP_PROMPTS_CONSOLE | SD_IPC_CAP_PROMPTS_UI)) && + sd_ipc_send_to(g.srv, h->initiator, (const char *)w.p, w.len) == SD_OK) { + delivered++; + to_initiator = h->initiator; + } + { + uint32_t n = 0; + if (sd_ipc_broadcast_count(g.srv, "prompt", (const char *)w.p, w.len, SD_IPC_CAP_PROMPTS_UI, to_initiator, + &n) == SD_OK) + delivered += n; + } + } + sd_wr_free(&w); + } + if (delivered) { + sd_mutex_unlock(&g.lock); + return; + } + + /* step 3 and 4: nothing took it. Start a UI if that is possible, then queue (16 at most). */ + r->queued = 1; + maybe_launch_ui(); + { + host_prompt *o; + for (o = g.prompts; o; o = o->next) + if (o->queued && !o->expire) queued_n++; + } + while (queued_n > g.prompt_queue_max) { + host_prompt *old = NULL, *o; + for (o = g.prompts; o; o = o->next) + if (o->queued && !o->expire && o != r && (old == NULL || o->seq < old->seq)) old = o; + if (old == NULL) + break; + { + host_mount *oh = find_by_mount(old->m); + if (oh) scopy(oh->fail_code, sizeof(oh->fail_code), "prompt_unavailable"); + } + old->expire = 1; + old->reason = "superseded"; + queued_n--; + post(NULL, work_flush, NULL); + } + SD_LOGI("agent", "%s: prompt %s queued (no UI or console client)", h->id, pid); sd_mutex_unlock(&g.lock); } static void on_prompt_closed(void *ctx, sd_mount *m, uint32_t prompt_id) { - host_mount *h; - char pid[32]; + host_prompt *r = NULL, **pp; SD_UNUSED(ctx); sd_mutex_lock(&g.lock); - h = find_by_mount(m); - if (h) { - sd_wr d; - sd_json_writer jw; - prompt_id_str(h, prompt_id, pid); - sd_wr_init(&d, 4096); - sd_jw_init(&jw, &d); - sd_jw_obj_begin(&jw); - sd_jw_key(&jw, "prompt_id"); sd_jw_cstr(&jw, pid); - sd_jw_key(&jw, "mount_id"); sd_jw_cstr(&jw, h->id); - sd_jw_key(&jw, "reason"); sd_jw_cstr(&jw, "answered"); - sd_jw_obj_end(&jw); - if (sd_jw_finish(&jw) == SD_OK) { - sd_wr w; - sd_wr_init(&w, 8192); - if (sd_ipc_build_event("prompt.closed", (const char *)d.p, d.len, &w) == SD_OK) { - if (h->initiator) - (void)sd_ipc_send_to(g.srv, h->initiator, (const char *)w.p, w.len); - sd_ipc_broadcast(g.srv, "prompt", (const char *)w.p, w.len); - } + for (pp = &g.prompts; *pp; pp = &(*pp)->next) + if ((*pp)->m == m && (*pp)->prompt_id == prompt_id) { + r = *pp; + *pp = r->next; + break; + } + if (r) { + /* a prompt the agent did not answer or expire was closed by the mount itself: cancelled */ + prompt_emit_closed(r, r->reason ? r->reason : "cancelled"); + prompt_free(r); + } + sd_mutex_unlock(&g.lock); +} + +/* A UI (or console) subscribed to "prompt": the queued prompts go to it first (oldest first), with + queued:true and the remaining deadline. Runs on the pipe thread. */ +static void ipc_on_subscribe(void *ctx, sd_ipc_client *client, const char *topic) +{ + uint64_t cid; + SD_UNUSED(ctx); + if (strcmp(topic, "prompt") != 0 || !(sd_ipc_client_caps(client) & SD_IPC_CAP_PROMPTS_UI)) + return; + cid = sd_ipc_client_id(client); + sd_mutex_lock(&g.lock); + for (;;) { + host_prompt *r = NULL, *o; + sd_wr w; + uint64_t now = sd_tick_ms(); + for (o = g.prompts; o; o = o->next) + if (o->queued && !o->expire && (r == NULL || o->seq < r->seq)) r = o; + if (r == NULL) + break; + sd_wr_init(&w, SD_IPC_MAX_MESSAGE); + if (prompt_request_event(r, 1, now, &w) != SD_OK || sd_ipc_send_to(g.srv, cid, (const char *)w.p, w.len) != SD_OK) { sd_wr_free(&w); + break; } - sd_wr_free(&d); + sd_wr_free(&w); + r->queued = 0; + SD_LOGI("agent", "%s: queued prompt %s delivered to a UI", r->mount_id, r->pid); } sd_mutex_unlock(&g.lock); } + /* ---- control-thread work items ---- */ +/* warning hostkey_source_insecure (03 §5.1, TM-R06): a live known_hosts source (OpenSSH, PuTTY) that other users can + write is skipped by every lookup. Checked when a mount starts, outside g.lock (the probe reads the file's security + and the registry key); the warning goes to the mount that started first, once per source and agent run. */ +static void warn_insecure_sources(host_mount *h) +{ + static const sd_kh_store_kind kinds[2] = { SD_KH_SRC_OPENSSH, SD_KH_SRC_PUTTY }; + static const char *const names[2] = { "openssh", "putty" }; + int on[2], i; + sd_mutex_lock(&g.lock); + on[0] = g.settings.openssh_source; + on[1] = g.settings.putty_source; + sd_mutex_unlock(&g.lock); + for (i = 0; i < 2; i++) { + sd_known_hosts *kh = NULL; + sd_kh_store store; + sd_kh_src_state st = SD_KH_SRC_OK; + wchar_t sid[256] = { 0 }; + uint32_t bit = 1u << i; + int warned; + if (!on[i]) + continue; + sd_mutex_lock(&g.lock); + warned = (g.kh_warned & bit) != 0; + sd_mutex_unlock(&g.lock); + if (warned) + continue; + memset(&store, 0, sizeof(store)); + store.kind = kinds[i]; /* the default path of the source */ + if (sd_known_hosts_open(&store, 1, &kh) != SD_OK) + continue; + if (sd_known_hosts_source_state(kh, kinds[i], &st, sid, SD_COUNTOF(sid)) == SD_OK && st == SD_KH_SRC_INSECURE) { + sd_mutex_lock(&g.lock); + if (agent_source_warn_take(&g.kh_warned, bit)) { + SD_LOGW("agent", "%s: known_hosts source %s is ignored: other users can change it", h->id, names[i]); + send_warning(h, "hostkey_source_insecure", NULL, 0, NULL, names[i]); + } + sd_mutex_unlock(&g.lock); + } + sd_known_hosts_close(kh); + } +} + static void work_start(void *arg) { host_mount *h = (host_mount *)arg; @@ -559,19 +1660,53 @@ static void work_start(void *arg) cb.on_notice = on_notice; cb.lookup_identity = lookup_identity; cb.store_identity = store_identity; + cb.secret_get = secret_get; + cb.secret_put = secret_put; + cb.on_warning = on_warning; + cb.on_hostkeys_announced = on_hostkeys_announced; + warn_insecure_sources(h); sd_mutex_lock(&g.lock); if (h->stop_requested) { /* unmounted before it started */ host_mount **pp; for (pp = &g.mounts; *pp; pp = &(*pp)->next) if (*pp == h) { *pp = h->next; break; } + state_publish_locked(0); /* never started: its CONNECTING entry goes */ sd_mutex_unlock(&g.lock); + free_chain(h->chain); sd_free(h); return; } + /* M3 status proxy_legs: the proxies of the chain, kept before sd_mount_create consumes the hops (no secrets) */ + h->nlegs = 0; + if (h->params.hops) { + uint32_t i; + for (i = 0; i < h->params.hop_count && h->nlegs < SD_MOUNT_MAX_HOPS; i++) { + const sd_mount_proxy *px = &h->params.hops[i].proxy; + host_leg *lg = &h->legs[h->nlegs]; + if (px->kind == SD_PROXY_NONE) + continue; + lg->hop = i + 1; + lg->kind = px->kind; + lg->port = px->port; + lg->host[0] = 0; + if (px->host) { + size_t got = 0; + if (sd_utf16_to_utf8(px->host, wcslen(px->host), lg->host, sizeof(lg->host) - 1, &got, SD_UTF_REPLACE) == SD_OK) + lg->host[got] = 0; + } + h->nlegs++; + } + } e = sd_mount_create(host_backend(), &h->params, &cb, &m); h->m = m; h->started = 1; + free_chain(h->chain); /* sd_mount copied it (key blobs included) */ + h->chain = NULL; + h->params.hops = NULL; + h->params.hop_count = 0; + h->params.kh_stores = NULL; + h->params.kh_store_count = 0; sd_mutex_unlock(&g.lock); if (e != SD_OK) { sd_mutex_lock(&g.lock); @@ -591,9 +1726,13 @@ static void work_start(void *arg) sd_jw_key(&jw, "exit_code"); sd_jw_int(&jw, sd_ipc_exit_code_for_err(e)); sd_jw_obj_end(&jw); sd_jw_obj_end(&jw); - if (sd_jw_finish(&jw) == SD_OK) broadcast_data("mount", "mount.state", &d); + if (!h->test_only) record_event("mount.state", h->id, "failed", sd_err_name(e)); + if (!h->test_only && sd_jw_finish(&jw) == SD_OK) broadcast_data("mount", "mount.state", &d); sd_wr_free(&d); } + if (h->test_only && h->defer_cid && g.srv) + (void)sd_ipc_reply_deferred(g.srv, h->defer_cid, h->defer_rid, e, NULL, "mount create failed", NULL, 0); + h->defer_cid = 0; destroy_record_locked(h); sd_mutex_unlock(&g.lock); return; @@ -607,20 +1746,24 @@ static void work_stop(void *arg) uint32_t num = (uint32_t)(uintptr_t)arg; host_mount *h; sd_mount *m = NULL; + int force = 0; sd_mutex_lock(&g.lock); for (h = g.mounts; h; h = h->next) if (h->num == num) break; - if (h && h->m && !sd_mount_is_final(h->m)) + if (h && h->m && !sd_mount_is_final(h->m)) { m = h->m; + force = h->stop_force; + } sd_mutex_unlock(&g.lock); /* sd_mount_stop calls on_state synchronously (which takes g.lock): never call it locked. */ if (m) - sd_mount_stop(m); + (void)sd_mount_stop_ex(m, force); } static void work_shutdown(void *arg) { SD_UNUSED(arg); + g.stop_reason = "request"; InterlockedExchange(&g.stop, 1); } @@ -628,13 +1771,17 @@ static void work_cancel_client(void *arg) { uint64_t cid = *(uint64_t *)arg; host_mount *h; + int ui; sd_free(arg); + /* section 10, decision 5: cancel only when no prompt-capable client is subscribed; otherwise the + mount keeps routing its prompts to that client */ + ui = sd_ipc_topic_subscribers(g.srv, "prompt", SD_IPC_CAP_PROMPTS_UI) > 0; sd_mutex_lock(&g.lock); for (h = g.mounts; h; h = h->next) { if (h->initiator == cid) { h->initiator = 0; - if (h->m && sd_mount_cancel_prompts(h->m) > 0) - scopy(h->fail_code, sizeof(h->fail_code), "prompt_required"); + if (!ui && h->m && sd_mount_cancel_prompts(h->m) > 0) + scopy(h->fail_code, sizeof(h->fail_code), "cancelled"); } } sd_mutex_unlock(&g.lock); @@ -649,7 +1796,7 @@ static int letter_in_use_locked(wchar_t l) if (drives & (1u << (l - L'A'))) return 1; for (h = g.mounts; h; h = h->next) - if (h->letter == l) return 1; + if (h->letter == l && !h->test_only) return 1; /* a test holds no drive */ return 0; } @@ -659,11 +1806,133 @@ static sd_err fail(sd_ipc_error *err, sd_err e, const char *code, const char *de return err->err; } -static sd_err handle_mount(sd_ipc_client *client, const sd_json *params, sd_json_writer *res, sd_ipc_error *err) +/* M2: sd_chain -> sd_mount_hop[] with every string the hops need (03 §3.4.1, §3.8). */ +static uint32_t agents_of(int agent) +{ + switch (agent) { + case SD_PROFILE_AGENT_PAGEANT: return SD_AGENT_PAGEANT; + case SD_PROFILE_AGENT_OPENSSH: return SD_AGENT_OPENSSH; + case SD_PROFILE_AGENT_NONE: return 0; + default: return SD_AGENT_PAGEANT | SD_AGENT_OPENSSH; + } +} + +static uint32_t methods_of(uint32_t pm) +{ + return ((pm & SD_PROFILE_AUTH_PUBLICKEY) ? SD_AUTH_PUBLICKEY : 0) | ((pm & SD_PROFILE_AUTH_KBDINT) ? SD_AUTH_KBDINT : 0) | + ((pm & SD_PROFILE_AUTH_PASSWORD) ? SD_AUTH_PASSWORD : 0); +} + +/* proxy_auth (3.8): auto = Basic or Kerberos-only Negotiate; negotiate also NTLM; basic; none. */ +static uint32_t proxy_auth_of(const sd_proxy_spec *x, int pa) +{ + if (x->kind == SD_PROFILE_PROXY_SOCKS5) + return x->user[0] ? SD_PROXY_AUTH_USERPASS : 0; + switch (pa) { + case SD_PROFILE_PROXY_AUTH_BASIC: return SD_PROXY_AUTH_BASIC; + case SD_PROFILE_PROXY_AUTH_NEGOTIATE: return SD_PROXY_AUTH_SSPI | (x->user[0] ? SD_PROXY_AUTH_BASIC : 0); + case SD_PROFILE_PROXY_AUTH_NONE: return 0; + default: return SD_PROXY_AUTH_SSPI_KERBEROS | (x->user[0] ? SD_PROXY_AUTH_BASIC : 0); + } +} + +static sd_err build_chain(host_mount *h, const sd_profile *prof, const sd_chain *ch, const char **why) +{ + host_chain *c = (host_chain *)sd_calloc(1, sizeof(*c)); + uint32_t i, k; + if (c == NULL) + return SD_E_NO_MEMORY; + h->chain = c; + c->count = ch->count; + for (i = 0; i < ch->count && i < SD_MOUNT_MAX_HOPS; i++) { + const sd_chain_hop *s = &ch->hop[i]; + sd_mount_hop *d = &c->hop[i]; + if (!wide_from_utf8(s->host, c->host[i], SD_COUNTOF(c->host[i]))) { *why = "invalid host"; return SD_E_INVALID_PARAM; } + d->host = c->host[i]; + d->port = s->port ? s->port : 22; + if (s->user[0]) scopy(c->user[i], sizeof(c->user[i]), s->user); + else scopy(c->user[i], sizeof(c->user[i]), prof->user); /* the Windows user name (filled above) */ + d->user = c->user[i]; + for (k = 0; k < s->identity_count && k < SD_MOUNT_MAX_IDENTITIES; k++) { + if (sd_config_expand_path(s->identity[k], c->ident[i][k], MAX_PATH) != SD_OK) { *why = "invalid identity path"; return SD_E_INVALID_PARAM; } + c->ident_ptr[i][k] = c->ident[i][k]; + } + d->identities = c->ident_ptr[i]; + d->identity_count = k; + if (s->certificate[0]) { + if (sd_config_expand_path(s->certificate, c->cert[i], MAX_PATH) != SD_OK) { *why = "invalid certificate path"; return SD_E_INVALID_PARAM; } + d->certificate = c->cert[i]; + } + if (s->identity_store[0]) { + /* a DPAPI-kept key (4.4): read the file, decrypt with the user's logon (sd_keyfile) */ + uint8_t *img = NULL; + size_t len = 0; + sd_err e = sd_secret_key_read(SD_SECRET_USER, s->identity_store, &img, &len); + if (e == SD_OK) + e = host_backend()->key_dpapi_unprotect ? host_backend()->key_dpapi_unprotect(img, len, s->identity_store, &c->key[i]) + : SD_E_UNSUPPORTED; + sd_free(img); + if (e != SD_OK) { + *why = "the protected key cannot be opened"; + return e == SD_E_NOT_FOUND ? SD_E_KEY_FORMAT : e; + } + d->key_blob = c->key[i]->p; + d->key_blob_len = c->key[i]->len; + scopy(c->key_label[i], sizeof(c->key_label[i]), s->identity_store); + d->key_label = c->key_label[i]; + } + d->agents = agents_of(s->agent); + d->identities_only = s->identities_only; + scopy(c->agent_keys[i], sizeof(c->agent_keys[i]), s->agent_keys); + d->agent_keys = c->agent_keys[i]; + d->auth_methods = methods_of(s->auth_methods); + scopy(c->alias[i], sizeof(c->alias[i]), s->host_key_alias); + d->host_key_alias = c->alias[i][0] ? c->alias[i] : NULL; + d->update_host_keys = s->update_host_keys; /* same values as sd_hku_mode */ + d->no_save = !s->save_password; + if (ch->proxy[i].kind != SD_PROFILE_PROXY_NONE) { + const sd_proxy_spec *x = &ch->proxy[i]; + d->proxy.kind = x->kind; /* same values as sd_proxy_kind */ + if (x->host[0] && !wide_from_utf8(x->host, c->proxy_host[i], SD_COUNTOF(c->proxy_host[i]))) { + *why = "invalid proxy"; + return SD_E_INVALID_PARAM; + } + d->proxy.host = c->proxy_host[i][0] ? c->proxy_host[i] : NULL; + d->proxy.port = x->port; + scopy(c->proxy_user[i], sizeof(c->proxy_user[i]), x->user); + d->proxy.user = c->proxy_user[i][0] ? c->proxy_user[i] : NULL; + d->proxy.auth = proxy_auth_of(x, ch->proxy_auth[i]); + d->proxy.fallback_direct = (ch->proxy_fallback_direct & (1u << i)) ? 1 : 0; + } + } + /* known_hosts (5.1): machine (read-only here), user, then the live sources the settings allow */ + c->stores[c->nstores].kind = SD_KH_STORE_MACHINE; + c->stores[c->nstores].writable = 0; + c->nstores++; + c->stores[c->nstores].kind = SD_KH_STORE_USER; + c->stores[c->nstores].path = h->kh[0] ? h->kh : NULL; + c->stores[c->nstores].writable = 1; + c->nstores++; + if (g.settings.openssh_source) { + c->stores[c->nstores].kind = SD_KH_SRC_OPENSSH; + c->nstores++; + } + if (g.settings.putty_source) { + c->stores[c->nstores].kind = SD_KH_SRC_PUTTY; + c->nstores++; + } + return SD_OK; +} + +/* test = 1 for profile.test (M3): the mount is a connect-only test (no drive letter, no list entry, no snapshot); + the reply is deferred to the end of the test. */ +static sd_err handle_mount_body(sd_ipc_client *client, const sd_json *params, sd_json_writer *res, sd_ipc_error *err, + sd_profile *prof, int test) { - sd_profile prof; const sd_json *adhoc = sd_json_get(params, "adhoc"); const char *pname = sd_json_get_str(params, "profile"); + if (pname == NULL) + pname = sd_json_get_str(params, "profile_id"); const char *target = sd_json_get_str(params, "target"); const char *pmode = sd_json_get_str(params, "prompt_mode"); const char *pbackend = sd_json_get_str(params, "backend"); @@ -674,25 +1943,39 @@ static sd_err handle_mount(sd_ipc_client *client, const sd_json *params, sd_json wchar_t root[MAX_PATH]; int ro = 0; sd_err e; + /* test = 2: hostkey add (M5). The caller resolved the profile; the request is a connect-only test */ + int hk = test == 2; + if (hk) + test = 1; - sd_profile_defaults(&prof); + if (!hk) { + sd_profile_defaults(prof); if (adhoc) { const char *v; int64_t port = 22; - if ((v = sd_json_get_str(adhoc, "host")) == NULL || strlen(v) >= sizeof(prof.host)) + if ((v = sd_json_get_str(adhoc, "host")) == NULL || strlen(v) >= sizeof(prof->host)) return fail(err, SD_E_INVALID_PARAM, "usage", "adhoc.host required"); - scopy(prof.host, sizeof(prof.host), v); - scopy(prof.name, sizeof(prof.name), v); + scopy(prof->host, sizeof(prof->host), v); + scopy(prof->name, sizeof(prof->name), v); if (sd_json_get(adhoc, "port") && (!sd_json_get_int64(adhoc, "port", &port) || port < 1 || port > 65535)) return fail(err, SD_E_INVALID_PARAM, "usage", "adhoc.port invalid"); - prof.port = (uint16_t)port; - if ((v = sd_json_get_str(adhoc, "user")) != NULL) scopy(prof.user, sizeof(prof.user), v); - if ((v = sd_json_get_str(adhoc, "identity")) != NULL) scopy(prof.identity, sizeof(prof.identity), v); - if ((v = sd_json_get_str(adhoc, "remote_path")) != NULL) scopy(prof.remote_path, sizeof(prof.remote_path), v); - if (sd_profile_validate(&prof, &bad) != SD_OK) + prof->port = (uint16_t)port; + if ((v = sd_json_get_str(adhoc, "user")) != NULL) scopy(prof->user, sizeof(prof->user), v); + if ((v = sd_json_get_str(adhoc, "identity")) != NULL) scopy(prof->identity, sizeof(prof->identity), v); + if ((v = sd_json_get_str(adhoc, "remote_path")) != NULL) scopy(prof->remote_path, sizeof(prof->remote_path), v); + /* M2: --jump and --proxy */ + if ((v = sd_json_get_str(adhoc, "jump")) != NULL) { + sd_jump_list jl; + if (strlen(v) >= sizeof(prof->jump) || sd_jump_parse(v, &jl) != SD_OK) + return fail(err, SD_E_INVALID_PARAM, "usage", "invalid jump host list"); + scopy(prof->jump, sizeof(prof->jump), v); + } + if ((v = sd_json_get_str(adhoc, "proxy")) != NULL && sd_proxy_spec_parse(v, &prof->proxy[0]) != SD_OK) + return fail(err, SD_E_INVALID_PARAM, "usage", "invalid proxy"); + if (sd_profile_validate(prof, &bad) != SD_OK) return fail(err, SD_E_INVALID_PARAM, "usage", bad); } else if (pname) { - e = sd_profile_find(pname, &prof, NULL, 0); + e = sd_profile_find_mountable(pname, prof, NULL, 0); /* the user's profiles, then the machine templates (03 §3.2) */ if (e == SD_E_NOT_FOUND) return fail(err, e, "profile_not_found", "no such profile"); if (e != SD_OK) @@ -700,27 +1983,44 @@ static sd_err handle_mount(sd_ipc_client *client, const sd_json *params, sd_json } else { return fail(err, SD_E_INVALID_PARAM, "usage", "profile or adhoc required"); } + } + { + /* M4 (03 §3.4, §3.5): the effective write mode. An unset mount.write_mode takes [defaults] write_mode; + ForceWriteThrough refuses an explicit behind */ + DWORD fv = 0; + int wm = prof->write_mode; + int force = sd_policy_get_dword(SD_POLICY_FORCEWRITETHROUGH, &fv) && fv != 0; + if (sd_profile_effective_write_mode(prof, &g.settings, force, &wm) != SD_OK) + return fail(err, SD_E_ACCESS_DENIED, "policy_denied", "write mode behind is not allowed by policy"); + prof->write_mode = wm; + } if (sd_json_get_bool(params, "read_only", &ro) && ro) - prof.read_only = 1; + prof->read_only = 1; + if (sd_json_get(params, "connections")) { + int64_t nc = 0; + if (!sd_json_get_int64(params, "connections", &nc) || nc < 1 || nc > 4) + return fail(err, SD_E_INVALID_PARAM, "usage", "connections must be 1 to 4"); + prof->connections = (uint32_t)nc; + } if (pbackend && !sd_mount_backend_kind_parse(pbackend, &backend)) return fail(err, SD_E_INVALID_PARAM, "usage", "backend must be auto, dokany or webdav"); if (pbackend == NULL) - backend = (sd_mount_backend_kind)prof.backend; /* mount.backend (same values) */ + backend = (sd_mount_backend_kind)prof->backend; /* mount.backend (same values) */ if (target == NULL || strcmp(target, "auto") == 0) - target = prof.target; + target = prof->target; if (strcmp(target, "auto") != 0) { if (!wide_from_utf8(target, wt, SD_COUNTOF(wt)) || (letter = sd_parse_drive_letter(wt)) == 0) return fail(err, SD_E_INVALID_PARAM, "invalid_target", "target must be a drive letter"); if (letter < L'D') return fail(err, SD_E_INVALID_PARAM, "invalid_target", "drive letters A: to C: are not used"); } - if (prof.user[0] == 0) { + if (prof->user[0] == 0) { wchar_t wu[128]; DWORD n = SD_COUNTOF(wu); size_t got = 0; if (GetUserNameW(wu, &n) && n > 0 && - sd_utf16_to_utf8(wu, n - 1, prof.user, sizeof(prof.user) - 1, &got, SD_UTF_STRICT) == SD_OK) - prof.user[got] = 0; + sd_utf16_to_utf8(wu, n - 1, prof->user, sizeof(prof->user) - 1, &got, SD_UTF_STRICT) == SD_OK) + prof->user[got] = 0; else return fail(err, SD_E_INVALID_PARAM, "usage", "user required"); } @@ -728,24 +2028,27 @@ static sd_err handle_mount(sd_ipc_client *client, const sd_json *params, sd_json h = (host_mount *)sd_calloc(1, sizeof(*h)); if (h == NULL) return fail(err, SD_E_NO_MEMORY, NULL, "out of memory"); - scopy(h->profile_name, sizeof(h->profile_name), prof.name); - scopy(h->profile_id, sizeof(h->profile_id), prof.id); - scopy(h->host, sizeof(h->host), prof.host); - scopy(h->user, sizeof(h->user), prof.user); - scopy(h->remote, sizeof(h->remote), prof.remote_path); - h->port = prof.port; - h->read_only = prof.read_only; + scopy(h->profile_name, sizeof(h->profile_name), prof->name); + scopy(h->profile_id, sizeof(h->profile_id), prof->id); + h->scope = prof->scope; + scopy(h->host, sizeof(h->host), prof->host); + scopy(h->user, sizeof(h->user), prof->user); + scopy(h->remote, sizeof(h->remote), prof->remote_path); + h->port = prof->port; + h->read_only = prof->read_only; h->backend_req = (int)backend; - h->initiator = sd_ipc_client_id(client); + /* a logon mount (client NULL) has no initiator: no prompts and no open after mount */ + h->initiator = client ? sd_ipc_client_id(client) : 0; + h->initiator_caps = client ? sd_ipc_client_caps(client) : 0; h->prompt_none = (pmode && strcmp(pmode, "none") == 0) || - !(sd_ipc_client_caps(client) & (SD_IPC_CAP_PROMPTS_CONSOLE | SD_IPC_CAP_PROMPTS_UI)); + !(h->initiator_caps & (SD_IPC_CAP_PROMPTS_CONSOLE | SD_IPC_CAP_PROMPTS_UI)); h->state = SD_MOUNT_CONNECTING; - if (!wide_from_utf8(prof.host, h->whost, SD_COUNTOF(h->whost)) || - !wide_from_utf8(prof.label[0] ? prof.label : prof.name, h->label, SD_COUNTOF(h->label))) { + if (!wide_from_utf8(prof->host, h->whost, SD_COUNTOF(h->whost)) || + !wide_from_utf8(prof->label[0] ? prof->label : prof->name, h->label, SD_COUNTOF(h->label))) { sd_free(h); return fail(err, SD_E_INVALID_PARAM, "usage", "invalid host or label"); } - if (prof.identity[0] && sd_config_expand_path(prof.identity, h->identity, SD_COUNTOF(h->identity)) != SD_OK) { + if (prof->identity[0] && sd_config_expand_path(prof->identity, h->identity, SD_COUNTOF(h->identity)) != SD_OK) { sd_free(h); return fail(err, SD_E_INVALID_PARAM, "usage", "invalid identity path"); } @@ -756,8 +2059,8 @@ static sd_err handle_mount(sd_ipc_client *client, const sd_json *params, sd_json { /* per-source cache folder (disk tier, identity cache): key "user@host:port" */ char key[512], pnum[8]; - size_t kn = strlen(prof.user) + strlen(prof.host) + 8; - uint32_t v = prof.port, pi = 0; + size_t kn = strlen(prof->user) + strlen(prof->host) + 8; + uint32_t v = prof->port, pi = 0; char tmp[8]; size_t tn = 0; do { tmp[tn++] = (char)('0' + v % 10u); v /= 10u; } while (v); @@ -765,9 +2068,9 @@ static sd_err handle_mount(sd_ipc_client *client, const sd_json *params, sd_json pnum[pi] = 0; if (kn < sizeof(key)) { key[0] = 0; - strcat_s(key, sizeof(key), prof.user); + strcat_s(key, sizeof(key), prof->user); strcat_s(key, sizeof(key), "@"); - strcat_s(key, sizeof(key), prof.host); + strcat_s(key, sizeof(key), prof->host); strcat_s(key, sizeof(key), ":"); strcat_s(key, sizeof(key), pnum); if (sd_config_cache_dir(key, h->cache_dir, SD_COUNTOF(h->cache_dir)) != SD_OK) { @@ -777,18 +2080,38 @@ static sd_err handle_mount(sd_ipc_client *client, const sd_json *params, sd_json } } + { + sd_chain *ch = NULL; + const char *why = NULL; + e = sd_chain_resolve(prof, NULL, NULL, &ch, &why); + if (e == SD_OK) { + e = build_chain(h, prof, ch, &why); + sd_free(ch); + } + if (e != SD_OK) { + free_chain(h->chain); + sd_free(h); + return fail(err, e, e == SD_E_INVALID_PARAM ? "usage" : NULL, why ? why : "cannot resolve the jump hosts"); + } + } sd_mutex_lock(&g.lock); for (o = g.mounts; o; o = o->next) { - if (prof.id[0] && strcmp(o->profile_id, prof.id) == 0) { + if (!test && prof->id[0] && strcmp(o->profile_id, prof->id) == 0) { sd_mutex_unlock(&g.lock); + free_chain(h->chain); sd_free(h); return fail(err, SD_E_EXISTS, "already_mounted", "profile already mounted"); } } - if (letter) { + if (hk) { + letter = 0; /* a hostkey mount has no drive (params.hostkey_fetch, 03 §6.1) */ + } else if (test) { + letter = L'Z'; /* profile.test has no drive: the letter only satisfies the mount record */ + } else if (letter) { if (letter_in_use_locked(letter)) { char detail[32]; sd_mutex_unlock(&g.lock); + free_chain(h->chain); sd_free(h); scopy(detail, sizeof(detail), "drive letter in use: X:"); detail[21] = (char)letter; /* the CLI reads the letter from the end of the detail */ @@ -800,11 +2123,13 @@ static sd_err handle_mount(sd_ipc_client *client, const sd_json *params, sd_json if (!letter_in_use_locked(l)) { letter = l; break; } if (letter == 0) { sd_mutex_unlock(&g.lock); + free_chain(h->chain); sd_free(h); return fail(err, SD_E_EXISTS, "target_in_use", "no free drive letter"); } } h->letter = letter; + h->test_only = test; h->num = ++g.next_num; { char tmp[12]; @@ -821,36 +2146,194 @@ static sd_err handle_mount(sd_ipc_client *client, const sd_json *params, sd_json h->params.port = h->port; h->params.user = h->user; h->params.identity_path = h->identity[0] ? h->identity : NULL; - h->params.auth_methods = ((prof.auth_methods & SD_PROFILE_AUTH_PUBLICKEY) ? SD_AUTH_PUBLICKEY : 0) | - ((prof.auth_methods & SD_PROFILE_AUTH_KBDINT) ? SD_AUTH_KBDINT : 0) | - ((prof.auth_methods & SD_PROFILE_AUTH_PASSWORD) ? SD_AUTH_PASSWORD : 0); + h->params.auth_methods = ((prof->auth_methods & SD_PROFILE_AUTH_PUBLICKEY) ? SD_AUTH_PUBLICKEY : 0) | + ((prof->auth_methods & SD_PROFILE_AUTH_KBDINT) ? SD_AUTH_KBDINT : 0) | + ((prof->auth_methods & SD_PROFILE_AUTH_PASSWORD) ? SD_AUTH_PASSWORD : 0); h->params.remote_path = h->remote[0] ? h->remote : NULL; h->params.letter = letter; - h->params.read_only = prof.read_only; - h->params.symlinks = prof.symlinks; - h->params.write_mode = prof.write_mode; + h->params.test_only = test; + if (hk) { + const char *v; + h->hk = 1; + scopy(h->hk_lookup, sizeof(h->hk_lookup), (v = sd_json_get_str(params, "lookup")) != NULL ? v : ""); + scopy(h->hk_type, sizeof(h->hk_type), (v = sd_json_get_str(params, "type")) != NULL ? v : ""); + scopy(h->hk_expect, sizeof(h->hk_expect), (v = sd_json_get_str(params, "expect")) != NULL ? v : ""); + h->params.hostkey_fetch = 1; + h->params.hostkey_type = h->hk_type[0] ? h->hk_type : NULL; + } + h->params.read_only = prof->read_only; + h->params.symlinks = prof->symlinks; + h->params.write_mode = prof->write_mode; h->params.volume_label = h->label; h->params.known_hosts_path = h->kh[0] ? h->kh : NULL; h->params.backend = h->backend_req; - h->params.disk_cache_mb = prof.disk_cache_mb; + h->params.disk_cache_mb = prof->disk_cache_mb; h->params.disk_cache_dir = h->cache_dir[0] ? h->cache_dir : NULL; - h->params.offline_wait_ms = prof.offline_wait_ms; - h->params.reconnect_grace_ms = prof.reconnect_grace_ms; - h->params.channels = prof.channels; + h->params.offline_wait_ms = prof->offline_wait_ms; + h->params.reconnect_grace_ms = prof->reconnect_grace_ms; + h->params.channels = prof->channels; h->params.cache_memory_bytes = g.settings.cache_memory; + /* M2 */ + h->params.hops = h->chain->hop; + h->params.hop_count = h->chain->count; + h->params.legacy_policy = prof->legacy_algorithms; /* same values as sd_legacy_policy */ + h->params.refuse_terrapin_exposed = prof->refuse_terrapin_exposed; + h->params.compression = prof->compression; + h->params.connections = prof->connections; + h->params.keepalive_ms = prof->keepalive_ms; + h->params.connect_timeout_ms = prof->connect_timeout_ms; + h->params.no_secret_cache = !prof->cache_secrets; + h->params.profile_name = h->label; + h->params.kh_stores = h->chain->stores; + h->params.kh_store_count = h->chain->nstores; + h->hop_count = h->chain->count; + scopy(h->legacy_accepted, sizeof(h->legacy_accepted), prof->legacy_accepted); + h->network_location = prof->network_location; + tuning_of(prof, &h->params.tuning); + { + /* mount.open_after_mount, or the open request of `shelldrive mount --open`; never for profile.test */ + int open_req = 0; + (void)sd_json_get_bool(params, "open", &open_req); + h->open_after = !test && (prof->open_after_mount || open_req); + } + /* profile.test: the request is answered when the test ends (on_state). sd_ipc_defer is valid inside the + handler only; it is taken before the record is linked, so a refusal leaves nothing behind. */ + if (test) { + if (!sd_ipc_defer(client, &h->defer_rid)) { + sd_mutex_unlock(&g.lock); + free_chain(h->chain); + sd_free(h); + return fail(err, SD_E_INTERNAL, NULL, "profile.test outside a request"); + } + h->defer_cid = sd_ipc_client_id(client); + } h->next = g.mounts; g.mounts = h; g.last_busy = sd_tick_ms(); - sd_jw_key(res, "mount_id"); sd_jw_cstr(res, h->id); - sd_jw_key(res, "target"); jw_letter(res, letter); - sd_jw_key(res, "profile"); sd_jw_cstr(res, h->profile_name); - sd_jw_key(res, "state"); sd_jw_cstr(res, "connecting"); + if (!test) { + state_publish_locked(0); + sd_jw_key(res, "mount_id"); sd_jw_cstr(res, h->id); + sd_jw_key(res, "target"); jw_letter(res, letter); + sd_jw_key(res, "profile"); sd_jw_cstr(res, h->profile_name); + sd_jw_key(res, "state"); sd_jw_cstr(res, "connecting"); + } sd_mutex_unlock(&g.lock); SD_LOGI("agent", "mount %s requested by client %llu", h->id, (unsigned long long)h->initiator); post(NULL, work_start, h); return SD_OK; } +static sd_err handle_mount(sd_ipc_client *client, const sd_json *params, sd_json_writer *res, sd_ipc_error *err) +{ + sd_profile *prof = (sd_profile *)sd_alloc(sizeof(sd_profile)); /* ~13 KB: off the IPC thread stack */ + sd_err e; + if (prof == NULL) + return fail(err, SD_E_NO_MEMORY, NULL, "out of memory"); + e = handle_mount_body(client, params, res, err, prof, 0); + sd_free(prof); + return e; +} + +/* profile.test {profile | profile_id | adhoc} (M3, 03 §2.4): connect, authenticate and realpath, no drive. The + prompts go through the normal broker. The reply is deferred (sd_ipc_defer) to the end of the test. */ +/* ---- M3 autostart (03 §3.4.1): profiles with autostart = logon are mounted when the agent starts ---- */ + +#define AUTOSTART_MAX 16u +typedef struct autostart_list { + char id[AUTOSTART_MAX][33]; + uint32_t n; +} autostart_list; + +static int autostart_sessions_ok(int want) +{ + int remote = GetSystemMetrics(SM_REMOTESESSION) != 0; + if (want == SD_PROFILE_SESSIONS_CONSOLE) return !remote; + if (want == SD_PROFILE_SESSIONS_REMOTE) return remote; + return 1; +} + +static int autostart_cb(void *vctx, const sd_profile *p, const wchar_t *path) +{ + autostart_list *l = (autostart_list *)vctx; + SD_UNUSED(path); + if (p->autostart == SD_PROFILE_AUTOSTART_NO) + return 1; + if (p->autostart == SD_PROFILE_AUTOSTART_BOOT) { + SD_LOGI("agent", "profile %s: autostart = boot is applied by the service only", p->id); + return 1; + } + if (p->scope != SD_PROFILE_SCOPE_USER) { + SD_LOGI("agent", "profile %s: autostart for a machine profile is not applied by the agent", p->id); + return 1; + } + if (!autostart_sessions_ok(p->autostart_sessions)) { + SD_LOGI("agent", "profile %s: autostart skipped (autostart_sessions does not match this session)", p->id); + return 1; + } + if (l->n < AUTOSTART_MAX && p->id[0]) { + scopy(l->id[l->n], sizeof(l->id[0]), p->id); + l->n++; + } + return 1; +} + +/* A logon mount is not a user request: no client, no prompt (prompt_none), no open after mount. */ +static void start_autostart(const char *id) +{ + sd_profile *prof = (sd_profile *)sd_alloc(sizeof(sd_profile)); + sd_json *params = NULL; + sd_wr w; + sd_json_writer jw; + sd_ipc_error err; + char text[96]; + sd_err e; + if (prof == NULL) + return; + strcpy_s(text, sizeof(text), "{\"profile_id\":\""); + strcat_s(text, sizeof(text), id); + strcat_s(text, sizeof(text), "\"}"); + if (sd_json_parse(text, strlen(text), 8, ¶ms) == SD_OK) { + sd_wr_init(&w, 4096); + sd_jw_init(&jw, &w); + sd_jw_obj_begin(&jw); + memset(&err, 0, sizeof(err)); + e = handle_mount_body(NULL, params, &jw, &err, prof, 0); + if (e != SD_OK) + SD_LOGW("agent", "autostart of profile %s failed: %s", id, err.code[0] ? err.code : sd_err_name(e)); + else + SD_LOGI("agent", "autostart of profile %s requested", id); + sd_wr_free(&w); + sd_json_free(params); + } + sd_free(prof); +} + +static void work_autostart(void *arg) +{ + autostart_list *l; + uint32_t i; + SD_UNUSED(arg); + l = (autostart_list *)sd_alloc(sizeof(*l)); + if (l == NULL) + return; + memset(l, 0, sizeof(*l)); + (void)sd_profile_enum(autostart_cb, l); + for (i = 0; i < l->n; i++) + start_autostart(l->id[i]); + sd_free(l); +} + +static sd_err handle_profile_test(sd_ipc_client *client, const sd_json *params, sd_json_writer *res, sd_ipc_error *err) +{ + sd_profile *prof = (sd_profile *)sd_alloc(sizeof(sd_profile)); + sd_err e; + if (prof == NULL) + return fail(err, SD_E_NO_MEMORY, NULL, "out of memory"); + e = handle_mount_body(client, params, res, err, prof, 1); + sd_free(prof); + return e; +} + static int mount_matches(host_mount *h, const char *target, const char *mount_id, const char *profile) { if (mount_id && strcmp(h->id, mount_id) == 0) return 1; @@ -867,18 +2350,22 @@ static sd_err handle_unmount(const sd_json *params, sd_json_writer *res, sd_ipc_ const char *target = sd_json_get_str(params, "target"); const char *mount_id = sd_json_get_str(params, "mount_id"); const char *profile = sd_json_get_str(params, "profile"); - int all = 0, n = 0; + int all = 0, force = 0, n = 0; host_mount *h; sd_json_get_bool(params, "all", &all); + sd_json_get_bool(params, "force", &force); /* M3: no write-behind drain budget (02 §4.4); see work_stop */ if (!all && !target && !mount_id && !profile) return fail(err, SD_E_INVALID_PARAM, "usage", "target, mount_id, profile or all required"); sd_mutex_lock(&g.lock); sd_jw_key(res, "mounts"); sd_jw_arr_begin(res); for (h = g.mounts; h; h = h->next) { + if (h->test_only) + continue; /* a profile test is not a mount: profile.test ends by itself */ if (all || mount_matches(h, target, mount_id, profile)) { if (!h->stop_requested) { h->stop_requested = 1; + h->stop_force = force ? 1 : 0; post(NULL, work_stop, (void *)(uintptr_t)h->num); } sd_jw_obj_begin(res); @@ -895,70 +2382,1103 @@ static sd_err handle_unmount(const sd_json *params, sd_json_writer *res, sd_ipc_ return SD_OK; } +static void remember_warning(const char *code, const char *mount_id) /* g.lock held */ +{ + host_warn *w = &g.warns[g.warn_next]; + scopy(w->code, sizeof(w->code), code); + scopy(w->mount_id, sizeof(w->mount_id), mount_id ? mount_id : ""); + g.warn_next = (g.warn_next + 1u) % WARN_RING; + if (g.warn_count < WARN_RING) + g.warn_count++; +} + static sd_err handle_list(sd_json_writer *res) { host_mount *h; + uint32_t i, start; sd_mutex_lock(&g.lock); sd_jw_key(res, "mounts"); sd_jw_arr_begin(res); for (h = g.mounts; h; h = h->next) - write_mount_json(res, h); + if (!h->test_only) + write_mount_json(res, h); + sd_jw_arr_end(res); + /* M3: the recent warnings of this agent, oldest first (codes of the warning events) */ + sd_jw_key(res, "warnings"); + sd_jw_arr_begin(res); + start = (g.warn_next + WARN_RING - g.warn_count) % WARN_RING; + for (i = 0; i < g.warn_count; i++) { + const host_warn *w = &g.warns[(start + i) % WARN_RING]; + sd_jw_obj_begin(res); + sd_jw_key(res, "code"); sd_jw_cstr(res, w->code); + if (w->mount_id[0]) { sd_jw_key(res, "mount_id"); sd_jw_cstr(res, w->mount_id); } + sd_jw_obj_end(res); + } sd_jw_arr_end(res); sd_mutex_unlock(&g.lock); return SD_OK; } -static sd_err handle_prompt_answer(const sd_json *params, sd_json_writer *res, sd_ipc_error *err) +/* M3 status stats (03 §2.4): the session and vfs counters of one mount and its pool members. g.lock held. */ +static void write_stats_json(sd_json_writer *jw, host_mount *h) { - const char *pid = sd_json_get_str(params, "prompt_id"); - const char *action = sd_json_get_str(params, "action"); - const sd_json *resp = sd_json_get(params, "responses"); - const char *r[SD_MOUNT_PROMPT_MAX_ITEMS]; - uint32_t mnum = 0, id = 0, n = 0, i; - sd_mount_answer_kind kind; - host_mount *h; - sd_err e = SD_E_NOT_FOUND; - if (!parse_prompt_id(pid, &mnum, &id) || action == NULL) - return fail(err, SD_E_INVALID_PARAM, "usage", "prompt_id and action required"); - if (strcmp(action, "accept") == 0) kind = SD_MA_HOSTKEY_TRUST; - else if (strcmp(action, "once") == 0) kind = SD_MA_HOSTKEY_ONCE; - else if (strcmp(action, "answer") == 0) kind = SD_MA_RESPONSES; - else if (strcmp(action, "reject") == 0 || strcmp(action, "cancel") == 0) kind = SD_MA_REJECT; - else return fail(err, SD_E_INVALID_PARAM, "usage", "unknown action"); - if (kind == SD_MA_RESPONSES) { - n = (uint32_t)sd_json_count(resp); - if (n == 0 || n > SD_MOUNT_PROMPT_MAX_ITEMS) - return fail(err, SD_E_INVALID_PARAM, "usage", "responses required"); - for (i = 0; i < n; i++) { - r[i] = sd_json_str(sd_json_at(resp, i), NULL); - if (r[i] == NULL) - return fail(err, SD_E_INVALID_PARAM, "usage", "responses must be strings"); - } + sd_mount_stats st; + sd_mount_conn c; + uint32_t i; + memset(&st, 0, sizeof(st)); + memset(&c, 0, sizeof(c)); + if (h->m) { + sd_mount_get_stats(h->m, &st); + sd_mount_get_conn(h->m, &c); } - sd_mutex_lock(&g.lock); - for (h = g.mounts; h; h = h->next) { - if (h->num == mnum && h->m) { - if (kind == SD_MA_REJECT && strcmp(action, "cancel") == 0) - scopy(h->fail_code, sizeof(h->fail_code), "cancelled"); - e = sd_mount_answer(h->m, id, kind, n, r); - break; - } + sd_jw_key(jw, "stats"); + sd_jw_obj_begin(jw); + sd_jw_key(jw, "session"); sd_jw_obj_begin(jw); + sd_jw_key(jw, "present"); sd_jw_bool(jw, st.has_session); + sd_jw_key(jw, "generation"); sd_jw_int(jw, st.generation); + sd_jw_key(jw, "channels"); sd_jw_int(jw, st.channels); + sd_jw_key(jw, "srtt_ms"); sd_jw_int(jw, st.srtt_ms); + sd_jw_key(jw, "rttvar_ms"); sd_jw_int(jw, st.rttvar_ms); + sd_jw_key(jw, "rx_bps"); sd_jw_int(jw, (int64_t)st.rx_bps); + sd_jw_key(jw, "tx_bps"); sd_jw_int(jw, (int64_t)st.tx_bps); + sd_jw_key(jw, "rx_bytes"); sd_jw_int(jw, (int64_t)st.rx_total); + sd_jw_key(jw, "tx_bytes"); sd_jw_int(jw, (int64_t)st.tx_total); + sd_jw_key(jw, "inflight"); sd_jw_int(jw, st.inflight); + sd_jw_key(jw, "queued"); sd_jw_int(jw, st.queued); + sd_jw_key(jw, "reconnect_attempts"); sd_jw_int(jw, st.reconnect_attempts); + sd_jw_obj_end(jw); + sd_jw_key(jw, "pool"); sd_jw_obj_begin(jw); + sd_jw_key(jw, "ready"); sd_jw_int(jw, c.pool); + sd_jw_key(jw, "wanted"); sd_jw_int(jw, c.pool_wanted); + sd_jw_key(jw, "members"); sd_jw_arr_begin(jw); + for (i = 0; i < st.members; i++) { + sd_jw_obj_begin(jw); + sd_jw_key(jw, "ready"); sd_jw_bool(jw, st.member[i].ready); + sd_jw_key(jw, "srtt_ms"); sd_jw_int(jw, st.member[i].srtt_ms); + sd_jw_key(jw, "inflight"); sd_jw_int(jw, st.member[i].inflight); + sd_jw_key(jw, "queued"); sd_jw_int(jw, st.member[i].queued); + sd_jw_key(jw, "rx_bytes"); sd_jw_int(jw, (int64_t)st.member[i].rx_total); + sd_jw_key(jw, "tx_bytes"); sd_jw_int(jw, (int64_t)st.member[i].tx_total); + sd_jw_obj_end(jw); } - sd_mutex_unlock(&g.lock); - if (e == SD_E_NOT_FOUND) - return fail(err, e, "not_found", "prompt not open"); - sd_jw_key(res, "accepted"); sd_jw_bool(res, e == SD_OK); - return SD_OK; + sd_jw_arr_end(jw); + sd_jw_obj_end(jw); + sd_jw_key(jw, "cache"); sd_jw_obj_begin(jw); + sd_jw_key(jw, "vfs"); sd_jw_bool(jw, st.has_vfs); + sd_jw_key(jw, "hits"); sd_jw_int(jw, (int64_t)st.cache_hits); + sd_jw_key(jw, "misses"); sd_jw_int(jw, (int64_t)st.cache_misses); + sd_jw_key(jw, "bytes_read"); sd_jw_int(jw, (int64_t)st.bytes_rd); + sd_jw_key(jw, "bytes_written"); sd_jw_int(jw, (int64_t)st.bytes_wr); + sd_jw_key(jw, "write_failed"); sd_jw_int(jw, (int64_t)st.write_failed); + sd_jw_key(jw, "write_behind_bytes"); sd_jw_int(jw, (int64_t)st.wb_bytes); + sd_jw_key(jw, "reconnects"); sd_jw_int(jw, (int64_t)st.reconnects); + sd_jw_obj_end(jw); + sd_jw_obj_end(jw); +} + +/* status proxy_legs (M3): the proxies of the chain, without credentials. g.lock held. */ +static void write_legs_json(sd_json_writer *jw, const host_mount *h) +{ + uint32_t i; + sd_jw_key(jw, "proxy_legs"); + sd_jw_arr_begin(jw); + for (i = 0; i < h->nlegs; i++) { + const host_leg *lg = &h->legs[i]; + sd_jw_obj_begin(jw); + sd_jw_key(jw, "hop"); sd_jw_int(jw, lg->hop); + sd_jw_key(jw, "kind"); + sd_jw_cstr(jw, lg->kind == SD_PROXY_HTTP ? "http" : lg->kind == SD_PROXY_SOCKS5 ? "socks5" + : lg->kind == SD_PROXY_SYSTEM ? "system" : "none"); + sd_jw_key(jw, "host"); sd_jw_cstr(jw, lg->host); + sd_jw_key(jw, "port"); sd_jw_int(jw, lg->port); + sd_jw_obj_end(jw); + } + sd_jw_arr_end(jw); +} + +/* status {mount_id | target, stats?}: the list object, the last failure, the connection details, and with + stats the session, pool and cache counters; proxy_legs always (03 §2.4, M3). */ +static sd_err handle_status(const sd_json *params, sd_json_writer *res, sd_ipc_error *err) +{ + const char *mid = sd_json_get_str(params, "mount_id"); + const char *target = sd_json_get_str(params, "target"); + int stats = 0; + host_mount *h; + if (mid == NULL && target == NULL) + return fail(err, SD_E_INVALID_PARAM, "usage", "mount_id or target required"); + sd_json_get_bool(params, "stats", &stats); + sd_mutex_lock(&g.lock); + for (h = g.mounts; h; h = h->next) + if (!h->test_only && mount_matches(h, target, mid, NULL)) break; + if (h == NULL) { + sd_mutex_unlock(&g.lock); + return fail(err, SD_E_NOT_FOUND, "mount_not_found", "no matching mount"); + } + sd_jw_key(res, "mount"); + write_mount_json(res, h); + if (stats) + write_stats_json(res, h); + write_legs_json(res, h); + if (h->m && (h->state == SD_MOUNT_FAILED || h->state == SD_MOUNT_OFFLINE)) { + sd_mount_failure f; + sd_mount_get_failure(h->m, &f); + sd_jw_key(res, "last_error"); + sd_jw_obj_begin(res); + sd_jw_key(res, "code"); sd_jw_cstr(res, final_code(h, &f)); + sd_jw_key(res, "detail"); sd_jw_cstr(res, f.detail); + sd_jw_obj_end(res); + } + sd_mutex_unlock(&g.lock); + return SD_OK; +} + +/* refresh {mount_id | target, path} (M3): drops the cached attributes and listings under path ("" or "/" = the + whole mount). Not mounted: not_mounted (exit 3). */ +static sd_err handle_refresh(const sd_json *params, sd_json_writer *res, sd_ipc_error *err) +{ + const char *mid = sd_json_get_str(params, "mount_id"); + const char *target = sd_json_get_str(params, "target"); + const char *path = sd_json_get_str(params, "path"); + wchar_t wp[1024]; + host_mount *h; + sd_err e; + size_t i; + int all; + if (mid == NULL && target == NULL) + return fail(err, SD_E_INVALID_PARAM, "usage", "mount_id or target required"); + if (path == NULL) + return fail(err, SD_E_INVALID_PARAM, "usage", "path required"); + if (!wide_from_utf8(path, wp, SD_COUNTOF(wp))) + return fail(err, SD_E_INVALID_PARAM, "usage", "path is too long or not UTF-8"); + for (i = 0; wp[i]; i++) + if (wp[i] == L'/') wp[i] = L'\\'; + all = wp[0] == 0 || (wp[0] == L'\\' && wp[1] == 0); + sd_mutex_lock(&g.lock); + for (h = g.mounts; h; h = h->next) + if (!h->test_only && mount_matches(h, target, mid, NULL)) break; + if (h == NULL) { + sd_mutex_unlock(&g.lock); + return fail(err, SD_E_NOT_FOUND, "mount_not_found", "no matching mount"); + } + e = h->m ? sd_mount_refresh(h->m, all ? NULL : wp) : SD_E_NOT_FOUND; + if (e == SD_OK) { + sd_jw_key(res, "mount_id"); sd_jw_cstr(res, h->id); + sd_jw_key(res, "path"); sd_jw_cstr(res, all ? "\\" : path); + sd_jw_key(res, "refreshed"); sd_jw_bool(res, 1); + } + sd_mutex_unlock(&g.lock); + if (e == SD_E_NOT_FOUND) + return fail(err, e, "not_mounted", "the mount has no file system to refresh"); + if (e == SD_E_UNSUPPORTED) + return fail(err, e, "unsupported", "refresh is not available in this build"); + return e == SD_OK ? SD_OK : fail(err, e, NULL, "refresh failed"); +} + +/* cache.clear {profile_id | mount_id | target | all} (M3): drops the memory blocks and the disk-tier files of the + matching mounts. Write-behind data still queued on one of them refuses the whole request with busy + (write_behind_pending, exit 8) unless force is set; the queued data is never dropped. */ +static sd_err handle_cache_clear(const sd_json *params, sd_json_writer *res, sd_ipc_error *err) +{ + const char *profile = sd_json_get_str(params, "profile_id"); + const char *mid = sd_json_get_str(params, "mount_id"); + const char *target = sd_json_get_str(params, "target"); + int all = 0, force = 0, matched = 0, cleared = 0, busy = 0; + host_mount *h; + sd_json_get_bool(params, "all", &all); + sd_json_get_bool(params, "force", &force); + if (!all && !profile && !mid && !target) + return fail(err, SD_E_INVALID_PARAM, "usage", "profile_id, mount_id, target or all required"); + sd_mutex_lock(&g.lock); + if (!force) { + for (h = g.mounts; h && !busy; h = h->next) { + sd_mount_stats st; + if (h->test_only || h->m == NULL || !(all || mount_matches(h, target, mid, profile))) + continue; + sd_mount_get_stats(h->m, &st); + if (st.has_vfs && st.wb_bytes > 0) + busy = 1; + } + if (busy) { + sd_mutex_unlock(&g.lock); + return fail(err, SD_E_BUSY, "write_behind_pending", "write-behind data is queued; use force to clear anyway"); + } + } + sd_jw_key(res, "mounts"); + sd_jw_arr_begin(res); + for (h = g.mounts; h; h = h->next) { + sd_err e; + if (h->test_only || h->m == NULL || !(all || mount_matches(h, target, mid, profile))) + continue; + matched++; + e = sd_mount_cache_clear(h->m, 1, force); + if (e == SD_OK) { + cleared++; + sd_jw_obj_begin(res); + sd_jw_key(res, "mount_id"); sd_jw_cstr(res, h->id); + sd_jw_obj_end(res); + } else if (e == SD_E_BUSY) { + busy = 1; /* queued meanwhile: this mount keeps its cache, the rest is reported */ + } + } + sd_jw_arr_end(res); + sd_jw_key(res, "cleared"); sd_jw_int(res, cleared); + sd_jw_key(res, "disk_tier"); sd_jw_bool(res, 1); + sd_mutex_unlock(&g.lock); + if (matched == 0 && !all) + return fail(err, SD_E_NOT_FOUND, "mount_not_found", "no matching mount"); + if (busy) + return fail(err, SD_E_BUSY, "write_behind_pending", "write-behind data was queued during the clear"); + return SD_OK; +} + +/* mount.attach_info {mount_id | target} (M3): the backend, the state and, for WebDAV, the loopback port and the + share root. The export token of the path (the capability in \\localhost@PORT\DavWWWRoot\s-) is never + returned. */ +static sd_err handle_attach_info(const sd_json *params, sd_json_writer *res, sd_ipc_error *err) +{ + const char *mid = sd_json_get_str(params, "mount_id"); + const char *target = sd_json_get_str(params, "target"); + host_mount *h; + sd_mount_attach a; + if (mid == NULL && target == NULL) + return fail(err, SD_E_INVALID_PARAM, "usage", "mount_id or target required"); + sd_mutex_lock(&g.lock); + for (h = g.mounts; h; h = h->next) + if (!h->test_only && mount_matches(h, target, mid, NULL)) break; + if (h == NULL) { + sd_mutex_unlock(&g.lock); + return fail(err, SD_E_NOT_FOUND, "mount_not_found", "no matching mount"); + } + memset(&a, 0, sizeof(a)); + if (h->m) + sd_mount_get_attach(h->m, &a); + else + a.backend = (sd_mount_backend_kind)h->backend_req; + sd_jw_key(res, "mount_id"); sd_jw_cstr(res, h->id); + sd_jw_key(res, "target"); jw_letter(res, h->letter); + sd_jw_key(res, "backend"); sd_jw_cstr(res, sd_mount_backend_kind_name(a.backend)); + sd_jw_key(res, "state"); sd_jw_cstr(res, sd_mount_state_name(h->state)); + sd_jw_key(res, "mounted"); sd_jw_bool(res, a.mounted); + if (a.webdav_port) { + char port[8], unc[64], url[64]; + size_t n = 0, o = 0; + uint32_t v = a.webdav_port; + char tmp[8]; + do { tmp[n++] = (char)('0' + v % 10u); v /= 10u; } while (v); + while (n) port[o++] = tmp[--n]; + port[o] = 0; + unc[0] = 0; + url[0] = 0; + strcat_s(unc, sizeof(unc), "\\\\localhost@"); + strcat_s(unc, sizeof(unc), port); + strcat_s(unc, sizeof(unc), "\\DavWWWRoot"); + strcat_s(url, sizeof(url), "http://localhost:"); + strcat_s(url, sizeof(url), port); + strcat_s(url, sizeof(url), "/"); + sd_jw_key(res, "webdav"); + sd_jw_obj_begin(res); + sd_jw_key(res, "host"); sd_jw_cstr(res, "localhost"); + sd_jw_key(res, "port"); sd_jw_int(res, a.webdav_port); + sd_jw_key(res, "unc_root"); sd_jw_cstr(res, unc); + sd_jw_key(res, "xp_url"); sd_jw_cstr(res, url); + sd_jw_obj_end(res); + } + sd_mutex_unlock(&g.lock); + return SD_OK; +} + +/* selftest {}: the in-process checks of `shelldrive-host --selftest` plus the live pipe (03 §6.1). */ +static sd_err handle_selftest(sd_json_writer *res) +{ + wchar_t root[MAX_PATH]; + sd_settings tmp; + int healthy = 1; + sd_jw_key(res, "checks"); + sd_jw_arr_begin(res); +#define SELFTEST_ROW(name_, ok_, code_, detail_) \ + do { \ + sd_jw_obj_begin(res); \ + sd_jw_key(res, "name"); sd_jw_cstr(res, name_); \ + sd_jw_key(res, "result"); sd_jw_cstr(res, (ok_) ? "pass" : "fail"); \ + if (!(ok_)) { sd_jw_key(res, "code"); sd_jw_cstr(res, code_); healthy = 0; } \ + sd_jw_key(res, "detail"); sd_jw_cstr(res, detail_); \ + sd_jw_obj_end(res); \ + } while (0) + { + int ok = sd_config_root(root, SD_COUNTOF(root)) == SD_OK; + SELFTEST_ROW("config_root", ok, "config_root_unavailable", ok ? "" : "no per-user root"); + } + { + sd_err e = sd_settings_load(&tmp); + SELFTEST_ROW("settings", e == SD_OK, sd_err_name(e), e == SD_OK ? "" : "settings.ini cannot be read"); + } + { + int ok = host_backend() != NULL && host_backend()->name != NULL; + SELFTEST_ROW("backend", ok, "backend_unavailable", ok ? host_backend()->name : "no backend"); + } + { + int ok; + sd_mutex_lock(&g.lock); + ok = g.srv != NULL; + sd_mutex_unlock(&g.lock); + SELFTEST_ROW("pipe", ok, "internal", ok ? "" : "the agent pipe is not served"); + } +#undef SELFTEST_ROW + sd_jw_arr_end(res); + sd_jw_key(res, "healthy"); + sd_jw_bool(res, healthy); + return SD_OK; +} + +/* ---- log.tail (03 §2.1 row 17, decision 12) ---- */ + +typedef struct tail_ctx { + sd_json_writer *jw; + const char *component; + uint32_t level_max; /* sd_log_level: records with a higher level are skipped */ + uint32_t lines; /* records to return */ + uint32_t skip; /* matching records to skip first (tail mode) */ + uint32_t matches; /* matching records seen (counting pass, emit pass) */ + uint32_t emitted; + uint64_t last_seq; +} tail_ctx; + +static const char *const k_level_names[] = { "error", "warn", "info", "debug", "trace" }; + +static int tail_match(const tail_ctx *t, const log_ring_rec *r) +{ + if (r->level < 0 || (uint32_t)r->level > t->level_max) + return 0; + return t->component == NULL || strcmp(r->component, t->component) == 0; +} + +static int tail_count_cb(void *vctx, const log_ring_rec *r) +{ + tail_ctx *t = (tail_ctx *)vctx; + if (tail_match(t, r)) + t->matches++; + return 1; +} + +static int tail_emit_cb(void *vctx, const log_ring_rec *r) +{ + tail_ctx *t = (tail_ctx *)vctx; + if (!tail_match(t, r)) + return 1; + if (t->matches++ < t->skip) + return 1; + sd_jw_obj_begin(t->jw); + sd_jw_key(t->jw, "seq"); sd_jw_int(t->jw, (int64_t)r->seq); + sd_jw_key(t->jw, "time"); sd_jw_cstr(t->jw, r->time); + sd_jw_key(t->jw, "level"); sd_jw_cstr(t->jw, k_level_names[r->level]); + sd_jw_key(t->jw, "component"); sd_jw_cstr(t->jw, r->component); + if (r->mount_id[0]) { sd_jw_key(t->jw, "mount_id"); sd_jw_cstr(t->jw, r->mount_id); } + sd_jw_key(t->jw, "text"); sd_jw_cstr(t->jw, r->text); + sd_jw_obj_end(t->jw); + t->emitted++; + t->last_seq = r->seq; + return t->emitted < t->lines; +} + +static int noop_cb(void *ctx, const log_ring_rec *r) +{ + SD_UNUSED(ctx); + SD_UNUSED(r); + return 0; +} + +static size_t put_hex(char *o, uint64_t v) +{ + char tmp[17]; + size_t n = 0, k = 0; + do { tmp[n++] = "0123456789abcdef"[v & 15u]; v >>= 4; } while (v); + while (n) o[k++] = tmp[--n]; + o[k] = 0; + return k; +} + +static void put_cursor(char *o, size_t cap, uint64_t seq) +{ + size_t k = put_hex(o, (uint64_t)GetCurrentProcessId()); + char tmp[24]; + size_t n = 0; + if (k + 1 < cap) o[k++] = '.'; + k += put_hex(o + k, g.boot_tick); + if (k + 1 < cap) o[k++] = '.'; + do { tmp[n++] = (char)('0' + seq % 10u); seq /= 10u; } while (seq); + while (n && k + 1 < cap) o[k++] = tmp[--n]; + o[k] = 0; +} + +/* "..": false on any other shape. */ +static int parse_cursor(const char *s, uint64_t *pid, uint64_t *boot, uint64_t *seq) +{ + uint64_t *field[3]; + int part; + field[0] = pid; + field[1] = boot; + field[2] = seq; + for (part = 0; part < 3; part++) { + uint64_t v = 0; + int digits = 0, hex = part < 2; + while (*s && *s != '.') { + int d; + char c = *s; + if (c >= '0' && c <= '9') d = c - '0'; + else if (hex && c >= 'a' && c <= 'f') d = c - 'a' + 10; + else if (hex && c >= 'A' && c <= 'F') d = c - 'A' + 10; + else return 0; + if (digits >= (hex ? 16 : 19)) return 0; + v = hex ? (v << 4) | (uint64_t)d : v * 10u + (uint64_t)d; + digits++; + s++; + } + if (digits == 0) + return 0; + *field[part] = v; + if (part < 2) { + if (*s != '.') + return 0; + s++; + } + } + return *s == 0; +} + +static sd_err handle_log_tail(const sd_json *params, sd_json_writer *res, sd_ipc_error *err) +{ + int64_t lines = 200; + int follow = 0; + const char *level = sd_json_get_str(params, "level"); + const char *cursor = sd_json_get_str(params, "cursor"); + tail_ctx t; + uint64_t oldest = 0, newest = 0, from = 0, dropped = 0, cpid = 0, cboot = 0, cseq = 0, next = 0; + int valid = 0, reset = 0; + char cur[80]; + memset(&t, 0, sizeof(t)); + t.level_max = (uint32_t)SD_LOG_TRACE; + if (sd_json_get(params, "lines") && (!sd_json_get_int64(params, "lines", &lines) || lines < 1 || lines > 2000)) + return fail(err, SD_E_INVALID_PARAM, "usage", "lines must be 1 to 2000"); + sd_json_get_bool(params, "follow", &follow); + if (follow) + return fail(err, SD_E_UNSUPPORTED, "unsupported", "follow is not available in this build"); + if (level) { + uint32_t i; + for (i = 0; i < SD_COUNTOF(k_level_names); i++) + if (strcmp(level, k_level_names[i]) == 0) break; + if (i == SD_COUNTOF(k_level_names)) + return fail(err, SD_E_INVALID_PARAM, "usage", "level must be error, warn, info, debug or trace"); + t.level_max = i; + } + t.component = sd_json_get_str(params, "component"); + t.lines = (uint32_t)lines; + if (cursor) { + if (parse_cursor(cursor, &cpid, &cboot, &cseq) && cpid == (uint64_t)GetCurrentProcessId() && cboot == g.boot_tick) + valid = 1; + else + reset = 1; /* foreign, malformed or from an earlier agent: start at the oldest */ + } + log_ring_scan(~(uint64_t)0, &oldest, &newest, noop_cb, NULL); /* range only */ + if (valid) { + from = cseq + 1; + if (newest && from < oldest) { + dropped = oldest - from; + from = oldest; + reset = 1; + } + } else if (cursor) { + from = 0; + } else { + /* tail: the last `lines` matching records */ + log_ring_scan(0, NULL, NULL, tail_count_cb, &t); + t.skip = t.matches > t.lines ? t.matches - t.lines : 0; + t.matches = 0; + } + sd_jw_key(res, "records"); + sd_jw_arr_begin(res); + t.jw = res; + log_ring_scan(from, NULL, NULL, tail_emit_cb, &t); + sd_jw_arr_end(res); + if (t.emitted && t.emitted >= t.lines) { + next = t.last_seq; /* more may follow: continue right after the last one */ + } else { + next = newest; /* everything up to the newest record was looked at */ + if (valid && cseq > next) + next = cseq; + } + put_cursor(cur, sizeof(cur), next); + sd_jw_key(res, "cursor"); sd_jw_cstr(res, cur); + sd_jw_key(res, "reset"); sd_jw_bool(res, reset); + sd_jw_key(res, "dropped"); sd_jw_int(res, (int64_t)dropped); + return SD_OK; +} + +/* ---- config.reload {scope?} (decision 3: the service only relies on this in M3) ---- */ + +/* M4 (03 §10.1, TM-R31): [log] privacy and the LogPrivacy policy. Strict mode hashes host, user and remote path + with HMAC-SHA-256 under the per-installation key state\log.key. */ +static uint8_t g_log_key[32]; + +static void log_privacy_hash(void *ctx, const char *utf8, size_t len, uint8_t out[32]) +{ + sd_hmac_ctx c; + (void)ctx; + sd_hmac_init(&c, SD_HASH_SHA256, g_log_key, sizeof(g_log_key)); + sd_hmac_update(&c, utf8, len); + sd_hmac_final(&c, out); + sd_hmac_wipe(&c); +} + +static void log_privacy_apply(const sd_settings *s) +{ + DWORD v = 0; + int pol = sd_policy_get_dword(SD_POLICY_LOGPRIVACY, &v) ? (v ? 1 : 0) : -1; + int mode = sd_settings_effective_privacy(s, pol); + if (mode != SD_SETTINGS_PRIVACY_STRICT) { + sd_log_set_privacy(SD_LOG_PRIVACY_STANDARD, NULL, NULL); + } else if (sd_config_log_key(g_log_key) == SD_OK) { + sd_log_set_privacy(SD_LOG_PRIVACY_STRICT, log_privacy_hash, NULL); + } else { + sd_log_set_privacy(SD_LOG_PRIVACY_STRICT, NULL, NULL); /* no key: names print as [redacted] */ + SD_LOGW("agent", "log.key cannot be read or created: strict log privacy keeps names out of the log"); + } +} + +static int count_profile(void *ctx, const sd_profile *p, const wchar_t *path) +{ + SD_UNUSED(p); + SD_UNUSED(path); + (*(uint32_t *)ctx)++; + return 1; +} + +static sd_err handle_config_reload(const sd_json *params, sd_json_writer *res, sd_ipc_error *err) +{ + const char *scope = sd_json_get_str(params, "scope"); + sd_settings ns; + uint32_t profiles = 0; + int changed; + sd_err e; + if (scope && strcmp(scope, "machine") == 0) + return fail(err, SD_E_UNSUPPORTED, "unsupported", "machine scope is served by the service"); + if (scope && strcmp(scope, "user") != 0) + return fail(err, SD_E_INVALID_PARAM, "usage", "scope must be user or machine"); + e = sd_settings_load(&ns); + if (e != SD_OK) + return fail(err, e, NULL, "settings.ini cannot be read"); + sd_mutex_lock(&g.lock); + changed = memcmp(&g.settings, &ns, sizeof(ns)) != 0; + g.settings = ns; + sd_mutex_unlock(&g.lock); + if (changed) { + sd_log_set_level((sd_log_level)ns.log_level); + log_privacy_apply(&ns); + } + (void)sd_profile_enum(count_profile, &profiles); + sd_jw_key(res, "profiles"); + sd_jw_obj_begin(res); + sd_jw_key(res, "loaded"); sd_jw_int(res, profiles); + sd_jw_key(res, "errors"); sd_jw_arr_begin(res); sd_jw_arr_end(res); + sd_jw_obj_end(res); + sd_jw_key(res, "settings"); sd_jw_cstr(res, changed ? "reloaded" : "unchanged"); + sd_jw_key(res, "restart_required"); sd_jw_arr_begin(res); sd_jw_arr_end(res); + return SD_OK; +} + +/* prompt.answer {prompt_id, action, responses?, save?, save_comment?}: the first accepted answer wins. + The record gets its prompt.closed reason before sd_mount sees the answer (03 §3.3). */ +static sd_err handle_prompt_answer(sd_ipc_client *client, const sd_json *params, sd_json_writer *res, sd_ipc_error *err) +{ + const char *pid = sd_json_get_str(params, "prompt_id"); + const char *action = sd_json_get_str(params, "action"); + const sd_json *resp = sd_json_get(params, "responses"); + const char *rs[SD_MOUNT_PROMPT_MAX_ITEMS]; + uint32_t mnum = 0, id = 0, n = 0, i; + sd_mount_answer_kind kind; + host_mount *h; + sd_err e = SD_E_NOT_FOUND; + int save = 0; + const char *comment = sd_json_get_str(params, "save_comment"); + sd_json_get_bool(params, "save", &save); + if (!parse_prompt_id(pid, &mnum, &id) || action == NULL) + return fail(err, SD_E_INVALID_PARAM, "usage", "prompt_id and action required"); + if (strcmp(action, "accept") == 0) kind = SD_MA_HOSTKEY_TRUST; + else if (strcmp(action, "once") == 0) kind = SD_MA_HOSTKEY_ONCE; + else if (strcmp(action, "answer") == 0) kind = SD_MA_RESPONSES; + else if (strcmp(action, "reject") == 0 || strcmp(action, "cancel") == 0) kind = SD_MA_REJECT; + else return fail(err, SD_E_INVALID_PARAM, "usage", "unknown action"); + if (kind == SD_MA_RESPONSES) { + n = (uint32_t)sd_json_count(resp); + if (n == 0 || n > SD_MOUNT_PROMPT_MAX_ITEMS) + return fail(err, SD_E_INVALID_PARAM, "usage", "responses required"); + for (i = 0; i < n; i++) { + rs[i] = sd_json_str(sd_json_at(resp, i), NULL); + if (rs[i] == NULL) + return fail(err, SD_E_INVALID_PARAM, "usage", "responses must be strings"); + } + } + sd_mutex_lock(&g.lock); + for (h = g.mounts; h && e == SD_E_NOT_FOUND; h = h->next) { + host_prompt *r; + if (h->num != mnum || h->m == NULL) + continue; + for (r = g.prompts; r; r = r->next) + if (r->m == h->m && r->prompt_id == id && !r->expire) break; + if (r == NULL) + break; + if (kind == SD_MA_REJECT && strcmp(action, "cancel") == 0) { + scopy(h->fail_code, sizeof(h->fail_code), "cancelled"); + r->reason = "cancelled"; + } else { + r->reason = "answered"; + } + r->answered_by = strcmp(sd_ipc_client_kind(client), "ui") == 0 ? "ui" : "cli"; + if (save && comment && !wide_from_utf8(comment, h->save_comment, SD_COUNTOF(h->save_comment))) + h->save_comment[0] = 0; + e = sd_mount_answer_ex(h->m, id, kind, n, rs, save ? SD_MOUNT_ANSWER_SAVE : 0); + } + sd_mutex_unlock(&g.lock); + if (e == SD_E_NOT_FOUND) + return fail(err, e, "not_found", "prompt not open"); + sd_jw_key(res, "accepted"); sd_jw_bool(res, e == SD_OK); + return SD_OK; +} + +/* prompt.list {}: the open and queued prompts (03 §2.1 row 11). */ +static sd_err handle_prompt_list(sd_json_writer *res) +{ + host_prompt *r; + uint64_t now = sd_tick_ms(); + sd_mutex_lock(&g.lock); + sd_jw_key(res, "prompts"); + sd_jw_arr_begin(res); + for (r = g.prompts; r; r = r->next) { + if (r->expire) + continue; + sd_jw_obj_begin(res); + sd_jw_key(res, "prompt_id"); sd_jw_cstr(res, r->pid); + sd_jw_key(res, "mount_id"); sd_jw_cstr(res, r->mount_id); + sd_jw_key(res, "profile_id"); sd_jw_cstr(res, r->profile_id); + sd_jw_key(res, "kind"); sd_jw_cstr(res, r->kind); + sd_jw_key(res, "queued"); sd_jw_bool(res, r->queued); + sd_jw_key(res, "deadline_ms"); sd_jw_int(res, (int64_t)(r->deadline > now ? r->deadline - now : 0)); + sd_jw_obj_end(res); + } + sd_jw_arr_end(res); + sd_mutex_unlock(&g.lock); + return SD_OK; +} + +static sd_err handle_announced_list(sd_json_writer *res) +{ + host_announce *x; + sd_mutex_lock(&g.lock); + sd_jw_key(res, "announcements"); + sd_jw_arr_begin(res); + for (x = g.announces; x; x = x->next) + write_announce_json(res, x); + sd_jw_arr_end(res); + sd_mutex_unlock(&g.lock); + return SD_OK; +} + +/* hostkey.announced.accept {id, add_only}: applies the reviewed announcement to the user store. */ +static sd_err handle_announced_accept(const sd_json *params, sd_json_writer *res, sd_ipc_error *err) +{ + const char *id = sd_json_get_str(params, "id"); + int add_only = 0; + host_announce **pp, *x = NULL; + sd_kh_store st; + sd_err e; + sd_json_get_bool(params, "add_only", &add_only); + if (id == NULL) + return fail(err, SD_E_INVALID_PARAM, "usage", "id required"); + sd_mutex_lock(&g.lock); + for (pp = &g.announces; *pp; pp = &(*pp)->next) + if (strcmp((*pp)->id, id) == 0) { x = *pp; *pp = x->next; break; } + sd_mutex_unlock(&g.lock); + if (x == NULL) + return fail(err, SD_E_NOT_FOUND, "announcement_not_found", "no such announcement"); + memset(&st, 0, sizeof(st)); + st.kind = SD_KH_STORE_USER; /* never the machine store from a user's connection */ + st.path = g.user_kh[0] ? g.user_kh : NULL; + st.writable = 1; + e = sd_mount_apply_hostkeys(host_backend(), &st, 1, &x->a, add_only); + if (e != SD_OK) { + sd_mutex_lock(&g.lock); /* keep it for another try */ + x->next = g.announces; + g.announces = x; + sd_mutex_unlock(&g.lock); + return fail(err, e, NULL, "cannot update known_hosts"); + } + SD_LOGI("agent", "host keys of %Hs updated (%u added, %u removed)", x->a.lookup_name, (unsigned)x->a.add_count, + add_only ? 0u : (unsigned)x->a.remove_count); + sd_jw_key(res, "host"); sd_jw_cstr(res, x->a.host); + sd_jw_key(res, "added"); sd_jw_int(res, x->a.add_count); + sd_jw_key(res, "removed"); sd_jw_int(res, add_only ? 0 : x->a.remove_count); + sd_free(x); + return SD_OK; +} + +/* hostkey.announced.reject {id} (03 §2.4, 5.6): drops a kept announcement without changing any store. The CLI sends it + when the user answers no to a hostkey add offer, so a refused offer leaves the list at once. */ +static sd_err handle_announced_reject(const sd_json *params, sd_json_writer *res, sd_ipc_error *err) +{ + const char *id = sd_json_get_str(params, "id"); + host_announce **pp, *x = NULL; + if (id == NULL) + return fail(err, SD_E_INVALID_PARAM, "usage", "id required"); + sd_mutex_lock(&g.lock); + for (pp = &g.announces; *pp; pp = &(*pp)->next) + if (strcmp((*pp)->id, id) == 0) { x = *pp; *pp = x->next; break; } + sd_mutex_unlock(&g.lock); + if (x == NULL) + return fail(err, SD_E_NOT_FOUND, "announcement_not_found", "no such announcement"); + SD_LOGI("agent", "host keys of %Hs not added (%s rejected)", x->a.lookup_name, x->id); + sd_jw_key(res, "host"); sd_jw_cstr(res, x->a.host); + sd_jw_key(res, "rejected"); sd_jw_bool(res, 1); + sd_free(x); + return SD_OK; +} + +/* ---- hostkey add (03 §6.1, 5.2, TM-R04) ---- + hostkey.add {host, port, lookup, type?, expect?}: the profile whose host and port match is used (its + authentication, jump hosts and proxies), else the host-only defaults of `mount --host`. The connection is a + hostkey mount (hostkey_fetch, sd_mount): it authenticates, offers only the stored key types of the host and + collects the other host keys inside that verified connection. The request is deferred: the reply is sent when the + mount ends (reply_hostkey_add), so the IPC thread never waits for the network. + expect: only the key with that SHA-256 fingerprint is stored, with no prompt (scripts); no match fails. Without + expect the keys are kept as an announcement for review: the CLI asks, then calls hostkey.announced.accept + {id, add_only: true}. A first contact (no stored key) offers the one key the server presented (5.2); accepting + it is the trust decision. Reply: {host, lookup_name, verified, keys[{type, fingerprint}], id? (pending), + added?, removed? (expect)}. */ +static int hk_type_ok(const char *t) +{ + size_t i, n = strlen(t); + if (n == 0 || n >= 64) return 0; + for (i = 0; i < n; i++) + if (!((t[i] >= 'a' && t[i] <= 'z') || (t[i] >= '0' && t[i] <= '9') || t[i] == '-' || t[i] == '@' || t[i] == '.')) + return 0; + return 1; +} + +/* The IPC code and the English detail of a failed mount for hostkey add (the CLI maps the code to its text). */ +static void hk_error_text(sd_err e, const char *mount_code, const char **code, char *detail, size_t cap) +{ + *code = "connect_failed"; + scopy(detail, cap, "cannot get the host keys of the server"); + if (e == SD_E_UNSUPPORTED) { + *code = "not_implemented"; + scopy(detail, cap, "hostkey add is not available on this backend"); + } else if (e == SD_E_HOSTKEY_MISMATCH) { + *code = "host_key_mismatch"; + scopy(detail, cap, "the server's host key does not match the stored one"); + } else if (e == SD_E_HOSTKEY_TYPE_UNAVAILABLE) { + *code = "host_key_type_unavailable"; + scopy(detail, cap, "the server offers none of the key types stored for it"); + } else if (e == SD_E_HOSTKEY_REJECTED) { + *code = "host_key_rejected"; + scopy(detail, cap, "the host key was not accepted"); + } else if (e == SD_E_INVALID_PARAM) { + *code = "usage"; + scopy(detail, cap, "invalid host"); + } else if (e == SD_E_NO_MEMORY) { + *code = NULL; + scopy(detail, cap, "out of memory"); + } else if (mount_code != NULL) { + *code = mount_code; + } +} + +/* The keys of a finished hostkey mount: the requested type only, fingerprints recomputed from the blobs. Writes the + reply members into jw (the caller opened and closes the object). On failure sets *code and detail (IPC) and + returns the error. */ +static sd_err hk_complete(sd_json_writer *jw, host_mount *h, sd_mount_hk_fetch *f, const char **code, char *detail, + size_t cap) +{ + uint32_t i, n = 0; + int found = -1; + const char *type = h->hk_type[0] ? h->hk_type : NULL; + const char *expect = h->hk_expect[0] ? h->hk_expect : NULL; + int verified = f->verified != 0; + for (i = 0; i < f->count; i++) { + if (type != NULL && strcmp(f->keys[i].type, type) != 0) + continue; + if (sd_hostkey_fingerprint_sha256(f->keys[i].blob, f->keys[i].len, f->keys[i].fp_sha256) != SD_OK) + continue; + if (n != i) + f->keys[n] = f->keys[i]; + n++; + } + f->count = n; + sd_jw_key(jw, "host"); sd_jw_cstr(jw, h->host); + sd_jw_key(jw, "lookup_name"); sd_jw_cstr(jw, h->hk_lookup); + sd_jw_key(jw, "verified"); sd_jw_bool(jw, verified); + if (expect != NULL) { + /* stored with no question: the user store only, as hostkey.announced.accept */ + sd_kh_store st; + host_announce *x; + sd_err e; + for (i = 0; i < n; i++) + if (strcmp(f->keys[i].fp_sha256, expect) == 0) { found = (int)i; break; } + if (found < 0) { + *code = verified ? "host_key_mismatch" : "host_key_rejected"; + scopy(detail, cap, "no key of the server has that fingerprint"); + return verified ? SD_E_HOSTKEY_MISMATCH : SD_E_HOSTKEY_REJECTED; + } + x = (host_announce *)sd_calloc(1, sizeof(*x)); + if (x == NULL) { + *code = NULL; + scopy(detail, cap, "out of memory"); + return SD_E_NO_MEMORY; + } + x->a.add[0] = f->keys[found]; + x->a.add_count = 1; + scopy(x->a.host, sizeof(x->a.host), h->host); + scopy(x->a.lookup_name, sizeof(x->a.lookup_name), h->hk_lookup); + memset(&st, 0, sizeof(st)); + st.kind = SD_KH_STORE_USER; /* the user store only, as hostkey.announced.accept */ + st.path = g.user_kh[0] ? g.user_kh : NULL; + st.writable = 1; + e = sd_mount_apply_hostkeys(host_backend(), &st, 1, &x->a, 1); + sd_free(x); + if (e != SD_OK) { + *code = NULL; + scopy(detail, cap, "cannot update known_hosts"); + return e; + } + SD_LOGI("agent", "hostkey add: stored one key of %Hs (%s)", h->hk_lookup, verified ? "verified" : "first contact"); + sd_jw_key(jw, "added"); sd_jw_int(jw, 1); + sd_jw_key(jw, "removed"); sd_jw_int(jw, 0); + write_keys_json(jw, "keys", f->keys, n); + return SD_OK; + } + write_keys_json(jw, "keys", f->keys, n); + if (n > 0) { + host_announce *x = (host_announce *)sd_calloc(1, sizeof(*x)); + unsigned long v; + if (x == NULL) { + *code = NULL; + scopy(detail, cap, "out of memory"); + return SD_E_NO_MEMORY; + } + memcpy(x->a.add, f->keys, n * sizeof(f->keys[0])); + x->a.add_count = n; + scopy(x->a.host, sizeof(x->a.host), h->host); + scopy(x->a.lookup_name, sizeof(x->a.lookup_name), h->hk_lookup); + sd_mutex_lock(&g.lock); + v = ++g.next_announce; + _snprintf_s(x->id, sizeof(x->id), _TRUNCATE, "a-%lu", v); + x->next = g.announces; + g.announces = x; + sd_mutex_unlock(&g.lock); + sd_jw_key(jw, "id"); sd_jw_cstr(jw, x->id); + SD_LOGI("agent", "hostkey add: %Hs offers %u key(s), kept for review (%s)", h->hk_lookup, (unsigned)n, x->id); + } + return SD_OK; +} + +/* control thread: the hostkey mount ended; the deferred hostkey.add request is answered. */ +static void reply_hostkey_add(host_mount *h, sd_mount *m, sd_err why) +{ + sd_mount_hk_fetch *f = NULL; + sd_mount_failure fl; + sd_wr d; + sd_json_writer jw; + const char *code = NULL; + char detail[256]; + sd_err e = why; + int ok = 0; + if (h->defer_cid == 0 || g.srv == NULL) + return; + detail[0] = 0; + if (why == SD_OK) { + f = (sd_mount_hk_fetch *)sd_calloc(1, sizeof(*f)); + e = f == NULL ? SD_E_NO_MEMORY : sd_mount_get_hostkeys(m, f); + } + if (e == SD_OK) { + sd_wr_init(&d, 16 * 1024); + sd_jw_init(&jw, &d); + sd_jw_obj_begin(&jw); + e = hk_complete(&jw, h, f, &code, detail, sizeof(detail)); + if (e == SD_OK) { + sd_jw_obj_end(&jw); + if (sd_jw_finish(&jw) == SD_OK) { + ok = 1; + } else { + e = SD_E_INTERNAL; + code = NULL; + scopy(detail, sizeof(detail), "reply too large"); + } + } + if (ok) { + sd_err se = sd_ipc_reply_deferred(g.srv, h->defer_cid, h->defer_rid, SD_OK, NULL, NULL, (const char *)d.p, d.len); + if (se != SD_OK && se != SD_E_NOT_FOUND) + SD_LOGW("agent", "%s: hostkey.add reply not sent (%s)", h->id, sd_err_name(se)); + } + sd_wr_free(&d); + } + if (!ok) { + if (why != SD_OK) { + /* the mount itself failed: its error decides the code */ + memset(&fl, 0, sizeof(fl)); + sd_mount_get_failure(m, &fl); + if (fl.err != SD_OK) + e = fl.err; + hk_error_text(e, fl.code, &code, detail, sizeof(detail)); + if (fl.detail[0] != 0 && code != NULL && strcmp(code, "connect_failed") == 0) + scopy(detail, sizeof(detail), fl.detail); + } + (void)sd_ipc_reply_deferred(g.srv, h->defer_cid, h->defer_rid, e, code, detail, NULL, 0); + } + sd_free(f); + h->defer_cid = 0; +} + +/* The profile of a hostkey add: the first profile with the host and port (as mount finds it), else the host-only + defaults of `mount --host`. The profile store is only read. */ +typedef struct hk_match { const char *host; uint16_t port; sd_profile *out; int found; } hk_match; + +static int hk_match_cb(void *ctx, const sd_profile *p, const wchar_t *path) +{ + hk_match *k = (hk_match *)ctx; + uint16_t pp = p->port ? p->port : 22; + SD_UNUSED(path); + if (p->host[0] != 0 && _stricmp(p->host, k->host) == 0 && pp == k->port) { + *k->out = *p; + k->found = 1; + return 0; + } + return 1; +} + +static sd_err handle_hostkey_add(sd_ipc_client *client, const sd_json *params, sd_json_writer *res, sd_ipc_error *err) +{ + const char *host = sd_json_get_str(params, "host"); + const char *lookup = sd_json_get_str(params, "lookup"); + const char *type = sd_json_get_str(params, "type"); + const char *expect = sd_json_get_str(params, "expect"); + const char *bad = NULL; + int64_t port = 22; + sd_profile *prof; + hk_match k; + sd_err e; + + sd_json_get_int64(params, "port", &port); + if (host == NULL || host[0] == 0 || lookup == NULL || lookup[0] == 0 || port < 1 || port > 65535) + return fail(err, SD_E_INVALID_PARAM, "usage", "host, port and lookup required"); + if (strlen(host) >= 256 || strlen(lookup) >= 300) + return fail(err, SD_E_INVALID_PARAM, "usage", "host or lookup too long"); + if (type != NULL && !hk_type_ok(type)) + return fail(err, SD_E_INVALID_PARAM, "usage", "invalid key type"); + if (expect != NULL && strlen(expect) >= 96) + return fail(err, SD_E_INVALID_PARAM, "usage", "invalid fingerprint"); + prof = (sd_profile *)sd_calloc(1, sizeof(sd_profile)); + if (prof == NULL) + return fail(err, SD_E_NO_MEMORY, NULL, "out of memory"); + memset(&k, 0, sizeof(k)); + k.host = host; + k.port = (uint16_t)port; + k.out = prof; + (void)sd_profile_enum(hk_match_cb, &k); + if (!k.found) { + sd_profile_defaults(prof); + scopy(prof->host, sizeof(prof->host), host); + scopy(prof->name, sizeof(prof->name), host); + prof->port = (uint16_t)port; + if (sd_profile_validate(prof, &bad) != SD_OK) { + sd_free(prof); + return fail(err, SD_E_INVALID_PARAM, "usage", bad ? bad : "invalid host"); + } + } + e = handle_mount_body(client, params, res, err, prof, 2); + sd_free(prof); + return e; +} + +/* diag.collect (03 §10.5, 6.1): the agent's own data for "diag --bundle". Counts and warning codes only: no host + name, user name or remote path is returned, so the bundle has nothing to anonymize from here. Open to every + client of the pipe (the same as status counters). */ +static sd_err handle_diag_collect(sd_json_writer *res) +{ + host_mount *h; + uint32_t i, start, n = 0; + sd_jw_key(res, "version"); sd_jw_cstr(res, SD_VERSION_STRING); + sd_jw_key(res, "pid"); sd_jw_int(res, (int64_t)GetCurrentProcessId()); + sd_mutex_lock(&g.lock); + for (h = g.mounts; h; h = h->next) + if (!h->test_only) + n++; + sd_jw_key(res, "mounts"); sd_jw_int(res, (int64_t)n); + sd_jw_key(res, "warnings"); + sd_jw_arr_begin(res); + start = (g.warn_next + WARN_RING - g.warn_count) % WARN_RING; + for (i = 0; i < g.warn_count; i++) { + const host_warn *w = &g.warns[(start + i) % WARN_RING]; + sd_jw_cstr(res, w->code); + } + sd_jw_arr_end(res); + sd_mutex_unlock(&g.lock); + return SD_OK; +} + +/* events.recent {limit?} (03 §2.4): the newest records of the event history, oldest first, at most EVENT_RING (200; + limit 1 to 200, default 200). Reply {events: [{seq, time, event, mount_id?, state?, code?}], last_seq}. seq counts + from 1 in this agent process; a client that started later sees the same history as the others. Open to every + client of the pipe, like list. */ +static sd_err handle_events_recent(const sd_json *params, sd_json_writer *res, sd_ipc_error *err) +{ + int64_t limit = EVENT_RING; + uint32_t n, first, k; + if (sd_json_get(params, "limit") && (!sd_json_get_int64(params, "limit", &limit) || limit < 1 || limit > EVENT_RING)) + return fail(err, SD_E_INVALID_PARAM, "usage", "limit must be 1 to 200"); + sd_mutex_lock(&g.evlock); + n = g.event_count < (uint32_t)limit ? g.event_count : (uint32_t)limit; + first = (g.event_next + EVENT_RING - n) % EVENT_RING; + sd_jw_key(res, "events"); + sd_jw_arr_begin(res); + for (k = 0; k < n; k++) { + const host_event *e = &g.events[(first + k) % EVENT_RING]; + sd_jw_obj_begin(res); + sd_jw_key(res, "seq"); sd_jw_int(res, (int64_t)e->seq); + sd_jw_key(res, "time"); sd_jw_int(res, e->time); + sd_jw_key(res, "event"); sd_jw_cstr(res, e->event); + if (e->mount_id[0]) { sd_jw_key(res, "mount_id"); sd_jw_cstr(res, e->mount_id); } + if (e->state[0]) { sd_jw_key(res, "state"); sd_jw_cstr(res, e->state); } + if (e->code[0]) { sd_jw_key(res, "code"); sd_jw_cstr(res, e->code); } + sd_jw_obj_end(res); + } + sd_jw_arr_end(res); + sd_jw_key(res, "last_seq"); sd_jw_int(res, (int64_t)g.event_seq); + sd_mutex_unlock(&g.evlock); + return SD_OK; } static sd_err ipc_handler(void *ctx, sd_ipc_client *client, const char *method, const sd_json *params, sd_json_writer *res, sd_ipc_error *err) { SD_UNUSED(ctx); + if (strcmp(method, "hostkey.announced.list") == 0) return handle_announced_list(res); + if (strcmp(method, "hostkey.announced.accept") == 0) return handle_announced_accept(params, res, err); + if (strcmp(method, "hostkey.announced.reject") == 0) return handle_announced_reject(params, res, err); + if (strcmp(method, "hostkey.add") == 0) return handle_hostkey_add(client, params, res, err); if (strcmp(method, "mount") == 0) return handle_mount(client, params, res, err); if (strcmp(method, "unmount") == 0) return handle_unmount(params, res, err); if (strcmp(method, "list") == 0) return handle_list(res); - if (strcmp(method, "prompt.answer") == 0) return handle_prompt_answer(params, res, err); + if (strcmp(method, "status") == 0) return handle_status(params, res, err); + if (strcmp(method, "selftest") == 0) return handle_selftest(res); + if (strcmp(method, "events.recent") == 0) return handle_events_recent(params, res, err); + if (strcmp(method, "diag.collect") == 0) return handle_diag_collect(res); + if (strcmp(method, "profile.test") == 0) return handle_profile_test(client, params, res, err); + if (strcmp(method, "refresh") == 0) return handle_refresh(params, res, err); + if (strcmp(method, "cache.clear") == 0) return handle_cache_clear(params, res, err); + if (strcmp(method, "mount.attach_info") == 0) return handle_attach_info(params, res, err); + if (strcmp(method, "log.tail") == 0) return handle_log_tail(params, res, err); + if (strcmp(method, "config.reload") == 0) return handle_config_reload(params, res, err); + if (strcmp(method, "prompt.answer") == 0) return handle_prompt_answer(client, params, res, err); + if (strcmp(method, "prompt.list") == 0) return handle_prompt_list(res); + if (strcmp(method, "update.check") == 0) return upd_method_check(g.srv, client, params, res, err); + if (strcmp(method, "update.apply") == 0) return upd_method_apply(g.srv, client, params, res, err); if (strcmp(method, "agent.shutdown") == 0) { post(NULL, work_shutdown, NULL); sd_jw_key(res, "stopping"); sd_jw_bool(res, 1); @@ -999,6 +3519,72 @@ static void cleanup_webdav_mappings(const char *when) } } +/* ---- M3 config.changed and agent.stopping (03 §2.4) ---- */ + +/* Event to every client that completed hello (events of the agent are not topic-bound here). */ +static void broadcast_all_json(const char *event, sd_wr *data) +{ + sd_wr w; + sd_wr_init(&w, SD_IPC_MAX_MESSAGE); + if (g.srv && sd_ipc_build_event(event, (const char *)data->p, data->len, &w) == SD_OK) + (void)sd_ipc_broadcast_all(g.srv, (const char *)w.p, w.len); + sd_wr_free(&w); +} + +/* config.changed {what: "settings" | "profiles", ids?}: runs on the watch thread. The ids of the profiles are the + ids of the changed files that still exist (a removed profile is not named; clients list again). */ +static void work_explorer_profiles(void *arg) +{ + SD_UNUSED(arg); + explorer_config_changed(); +} + +static void on_config_changed(void *ctx, const char *what, const wchar_t *const *paths, uint32_t npaths) +{ + sd_wr d; + sd_json_writer jw; + uint32_t i; + SD_UNUSED(ctx); + sd_wr_init(&d, 16 * 1024); + sd_jw_init(&jw, &d); + sd_jw_obj_begin(&jw); + sd_jw_key(&jw, "what"); sd_jw_cstr(&jw, what); + if (strcmp(what, "profiles") == 0) { + sd_jw_key(&jw, "ids"); + sd_jw_arr_begin(&jw); + for (i = 0; i < npaths; i++) { + sd_profile *p = (sd_profile *)sd_alloc(sizeof(sd_profile)); + const char *bad = NULL; + if (p != NULL && sd_profile_load_file(paths[i], p, &bad) == SD_OK && p->id[0]) + sd_jw_cstr(&jw, p->id); + sd_free(p); + } + sd_jw_arr_end(&jw); + } + sd_jw_obj_end(&jw); + if (sd_jw_finish(&jw) == SD_OK) + broadcast_all_json("config.changed", &d); + sd_wr_free(&d); + if (strcmp(what, "profiles") == 0) + post(NULL, work_explorer_profiles, NULL); /* Network locations follow saved, renamed and deleted profiles */ + SD_LOGI("agent", "configuration changed: %s", what); +} + +/* agent.stopping {reason}: sent once when the shutdown sequence starts (request, event or idle). */ +static void broadcast_stopping(void) +{ + sd_wr d; + sd_json_writer jw; + sd_wr_init(&d, 256); + sd_jw_init(&jw, &d); + sd_jw_obj_begin(&jw); + sd_jw_key(&jw, "reason"); sd_jw_cstr(&jw, g.stop_reason ? g.stop_reason : "request"); + sd_jw_obj_end(&jw); + if (sd_jw_finish(&jw) == SD_OK) + broadcast_all_json("agent.stopping", &d); + sd_wr_free(&d); +} + /* ---- main loop ---- */ static int mounts_empty(void) @@ -1017,12 +3603,52 @@ int host_agent_run(const host_agent_opts *opts) sd_ipc_server_cfg cfg; sd_err e; uint64_t deadline; + uint64_t next_sweep = 0; memset(&g, 0, sizeof(g)); + (void)log_ring_start(); + g.prompt_queue_max = PROMPT_QUEUE_MAX; + g.prompt_ms = PROMPT_DEADLINE_MS; + g.reconnect_prompt_ms = PROMPT_RECONNECT_MS; + g.ui_launch = 1; +#ifdef SD_HOST_TEST_HOOKS + g.prompt_ms = env_u32(L"SHELLDRIVE_TEST_PROMPT_MS", (uint32_t)g.prompt_ms); + g.reconnect_prompt_ms = env_u32(L"SHELLDRIVE_TEST_RECONNECT_PROMPT_MS", (uint32_t)g.reconnect_prompt_ms); + g.prompt_queue_max = env_u32(L"SHELLDRIVE_TEST_PROMPT_QUEUE_MAX", g.prompt_queue_max); + g.no_route = env_u32(L"SHELLDRIVE_TEST_PROMPT_NO_ROUTE", 0) != 0; + /* a test agent never starts the prompt UI: the UI would reach the default instance and start the real + agent. Only a test that asks for the launch sets SHELLDRIVE_TEST_UI_LAUNCH=1. */ + g.ui_launch = env_u32(L"SHELLDRIVE_TEST_UI_LAUNCH", 0) != 0; +#endif + g.boot_tick = sd_tick_ms(); (void)sd_settings_load(&g.settings); sd_log_set_level((sd_log_level)g.settings.log_level); + log_privacy_apply(&g.settings); + { + sd_secret_config sc; + wchar_t root[MAX_PATH]; + memset(&sc, 0, sizeof(sc)); + sc.persist_enterprise = g.settings.persist_enterprise; +#ifdef SD_HOST_TEST_HOOKS + { + /* tests: secrets live as DPAPI files under the test root, never in the real Credential Manager */ + static wchar_t tdir[MAX_PATH]; + if (GetEnvironmentVariableW(L"SHELLDRIVE_TEST_CONFIG_DIR", tdir, MAX_PATH) - 1u < MAX_PATH - 1u) { + sc.user_dir = tdir; + sc.prefix = "com.librestatic.shelldrive.test:"; + sc.force_dpapi = 1; + } + } +#endif + sd_secret_configure(&sc); + if (sd_config_root(root, SD_COUNTOF(root)) == SD_OK && wcslen(root) + 13 < SD_COUNTOF(g.user_kh)) { + memcpy(g.user_kh, root, (wcslen(root) + 1) * sizeof(wchar_t)); + wcscat_s(g.user_kh, SD_COUNTOF(g.user_kh), L"\\known_hosts"); + } + } sd_mutex_init(&g.lock); sd_mutex_init(&g.qlock); + sd_mutex_init(&g.evlock); g.qev = CreateEventW(NULL, FALSE, FALSE, NULL); if (g.qev == NULL) return 1; @@ -1042,10 +3668,23 @@ int host_agent_run(const host_agent_opts *opts) ResetEvent(stop_ev); } + /* M3: the snapshot section (03 §8.2.1) exists before any client can connect; then the Explorer data + left by a crashed agent is reconciled (03 §8.3, §8.7). */ + explorer_init(); + if (host_state_open() != SD_OK) + SD_LOGW("agent", "state snapshot unavailable (named object squatted or no memory)"); + explorer_reconcile_start(); + /* menu.cache (03 §8.2.2) is rewritten on every profile change; one rewrite here also covers profiles made before it + existed or edited by hand. A failure only leaves the old file (or the empty submenu) in place. */ + if (sd_menucache_refresh() != SD_OK) + SD_LOGW("agent", "menu.cache not refreshed at start"); + memset(&cfg, 0, sizeof(cfg)); cfg.struct_size = sizeof(cfg); cfg.handler = ipc_handler; cfg.on_disconnect = ipc_on_disconnect; + cfg.on_subscribe = ipc_on_subscribe; + cfg.features = k_agent_features; e = sd_ipc_server_start_ex(SD_IPC_AGENT, &cfg, &g.srv); if (e != SD_OK) { SD_LOGE("agent", "cannot serve the agent pipe (%s)", sd_err_name(e)); @@ -1062,7 +3701,10 @@ int host_agent_run(const host_agent_opts *opts) another user could bind; remove them before anything else */ cleanup_webdav_mappings("start"); g_prev_filter = SetUnhandledExceptionFilter(crash_filter); + if (config_watch_start(on_config_changed, NULL) != SD_OK) + SD_LOGI("agent", "configuration watch not started (no config root)"); g.last_busy = sd_tick_ms(); + post(NULL, work_autostart, NULL); /* M3 mount.autostart = logon (03 §3.4.1) */ while (!g.stop) { HANDLE hs[2]; @@ -1072,21 +3714,31 @@ int host_agent_run(const host_agent_opts *opts) w = WaitForMultipleObjects(n, hs, FALSE, 1000); if (stop_ev && w == WAIT_OBJECT_0 + 1) { SD_LOGI("agent", "shutdown event signaled"); + g.stop_reason = "event"; break; } drain(); + if (sd_tick_ms() >= next_sweep) { + next_sweep = sd_tick_ms() + 1000; + prompt_sweep(); + } if (!opts->logon && opts->idle_exit_ms) { uint64_t now = sd_tick_ms(); if (!mounts_empty() || sd_ipc_server_client_count(g.srv) > 0) g.last_busy = now; else if (now - g.last_busy > opts->idle_exit_ms) { SD_LOGI("agent", "idle; exiting"); + g.stop_reason = "idle"; break; } } } - /* Shutdown sequence (1.2): stop every mount, wait (bounded) for teardown, then the pipe. */ + /* Shutdown sequence (1.2): the config watch stops, agent.stopping goes out, every mount is stopped, the wait + (bounded) for teardown, then the pipe. */ + config_watch_stop(); + broadcast_stopping(); + Sleep(100); /* the pipe thread writes the queued events; there is no flush call */ if (ready) ResetEvent(ready); { host_mount *h; @@ -1107,12 +3759,25 @@ int host_agent_run(const host_agent_opts *opts) sd_ipc_server_stop(g.srv); g.srv = NULL; drain(); + explorer_shutdown(); /* icons of this agent go; the snapshot is emptied below */ + host_state_close(); /* mounts that did not finish their teardown in time must not leave a mapping behind (TM-R17) */ cleanup_webdav_mappings("stop"); SetUnhandledExceptionFilter(g_prev_filter); + while (g.prompts) { + host_prompt *r = g.prompts; + g.prompts = r->next; + prompt_free(r); + } + while (g.announces) { + host_announce *x = g.announces; + g.announces = x->next; + sd_free(x); + } if (ready) CloseHandle(ready); if (stop_ev) CloseHandle(stop_ev); if (mutex) CloseHandle(mutex); SD_LOGI("agent", "agent stopped"); + log_ring_stop(); return 0; } diff --git a/apps/shelldrive-host/agent_events.c b/apps/shelldrive-host/agent_events.c new file mode 100644 index 0000000..0df0573 --- /dev/null +++ b/apps/shelldrive-host/agent_events.c @@ -0,0 +1,51 @@ +/* The rules of two agent events (see agent_events.h). Pure functions: no locks, no I/O. */ +#include + +#include "agent_events.h" + +static int is(const char *a, const char *b) +{ + return a != NULL && strcmp(a, b) == 0; +} + +static void copy_to(char *dst, size_t cap, const char *src) +{ + size_t i = 0; + if (cap == 0) + return; + if (src != NULL) + for (; src[i] != 0 && i + 1 < cap; i++) + dst[i] = src[i]; + dst[i] = 0; +} + +int agent_reason_on_state(char *drop, size_t cap, const char *st, const char *ecode, char *reason, size_t rcap) +{ + int carries, ends; + if (drop == NULL || cap == 0 || st == NULL) + return 0; + if (is(st, "offline")) { /* the offline event has its own error object */ + if (ecode != NULL && ecode[0] != 0) + copy_to(drop, cap, ecode); + return 0; + } + carries = is(st, "connecting") || is(st, "authenticating") || is(st, "mounted"); + ends = is(st, "mounted") || is(st, "failed") || is(st, "unmounting") || is(st, "unmounted"); + if (carries && drop[0] != 0 && reason != NULL && rcap > 0) { + copy_to(reason, rcap, drop); + if (ends) + drop[0] = 0; + return 1; + } + if (ends) + drop[0] = 0; + return 0; +} + +int agent_source_warn_take(uint32_t *done, uint32_t bit) +{ + if (done == NULL || bit == 0 || (*done & bit) != 0) + return 0; + *done |= bit; + return 1; +} diff --git a/apps/shelldrive-host/agent_events.h b/apps/shelldrive-host/agent_events.h new file mode 100644 index 0000000..e703aa9 --- /dev/null +++ b/apps/shelldrive-host/agent_events.h @@ -0,0 +1,20 @@ +/* The rules of two agent events (03 §2.4 mount.state reason, §5.1 warning hostkey_source_insecure), kept apart + * from agent.c so that the unit tests (tests/apps) can run them without a mount. */ +#ifndef SD_HOST_AGENT_EVENTS_H +#define SD_HOST_AGENT_EVENTS_H + +#include +#include + +/* mount.state `reason` (03 §2.4). drop: the error code that took the mount offline, kept per mount (cap bytes, "" when + none). st: sd_mount_state_name() of the new state; ecode: the error code of that state, NULL when it has none. + Returns 1 and copies the reason into reason when the event carries one: the code of the offline period is sent with + connecting, authenticating and mounted, and ends with the mounted state (or with failed, unmounting or unmounted). + Returns 0 otherwise. */ +int agent_reason_on_state(char *drop, size_t cap, const char *st, const char *ecode, char *reason, size_t rcap); + +/* hostkey_source_insecure is warned once per live source and agent run (03 §5.1). done: a bit mask of the sources + warned so far; bit: the source's bit (1u << index). Returns 1 when the bit was clear (the caller warns) and sets it. */ +int agent_source_warn_take(uint32_t *done, uint32_t bit); + +#endif /* SD_HOST_AGENT_EVENTS_H */ diff --git a/apps/shelldrive-host/config_watch.c b/apps/shelldrive-host/config_watch.c new file mode 100644 index 0000000..1e17b05 --- /dev/null +++ b/apps/shelldrive-host/config_watch.c @@ -0,0 +1,266 @@ +/* Watch of the user's configuration (see config_watch.h). One thread with two overlapped directory watches + * (the config root for settings.ini and the creation of the profiles folder, the profiles folder for *.ini) + * and one stop event. Debounce: a batch is delivered once 500 ms have passed since its last change. The + * buffers are on the heap: the thread's stack stays small (a batch of paths and two read buffers). */ +#include +#include + +#include "config_watch.h" +#include "sd_base/sd_mem.h" +#include "sd_config/sd_config.h" +#include "sd_platform/sd_log.h" +#include "sd_platform/sd_time.h" + +#define CW_LOG "agent" +#define CW_DEBOUNCE_MS 500u +#define CW_RETRY_MS 2000u /* reopen of the profiles folder while it does not exist */ +#define CW_BUF 16384 +#define CW_MAX_PATHS 64u +#define CW_FILTER (FILE_NOTIFY_CHANGE_FILE_NAME | FILE_NOTIFY_CHANGE_LAST_WRITE | FILE_NOTIFY_CHANGE_SIZE) + +typedef struct cw_dir { + HANDLE dir; /* INVALID_HANDLE_VALUE when not open */ + HANDLE ev; + OVERLAPPED ov; + BYTE buf[CW_BUF]; + int armed; +} cw_dir; + +/* Changes of one debounce window. */ +typedef struct cw_batch { + int settings, profiles, overflow, profiles_dir_added; + wchar_t paths[CW_MAX_PATHS][MAX_PATH]; + uint32_t npaths; +} cw_batch; + +/* The watch state of one run of the thread (heap allocated). */ +typedef struct cw_state { + cw_dir root, prof; + cw_batch batch; + uint64_t last_event, last_try; + int pending; +} cw_state; + +static struct { + HANDLE thread, stop; + config_watch_cb cb; + void *ctx; + wchar_t root[MAX_PATH], profiles[MAX_PATH]; +} g_cw; + +static void cw_close(cw_dir *d) +{ + if (d->dir != INVALID_HANDLE_VALUE) { + if (d->armed) + CancelIo(d->dir); + CloseHandle(d->dir); + } + if (d->ev) + CloseHandle(d->ev); + d->dir = INVALID_HANDLE_VALUE; + d->ev = NULL; + d->armed = 0; +} + +static int cw_open(cw_dir *d, const wchar_t *path) +{ + d->dir = CreateFileW(path, FILE_LIST_DIRECTORY, FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, NULL, + OPEN_EXISTING, FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OVERLAPPED, NULL); + if (d->dir == INVALID_HANDLE_VALUE) + return 0; + d->ev = CreateEventW(NULL, TRUE, FALSE, NULL); + if (d->ev == NULL) { + cw_close(d); + return 0; + } + d->armed = 0; + return 1; +} + +static void cw_arm(cw_dir *d) +{ + DWORD got = 0; + memset(&d->ov, 0, sizeof(d->ov)); + d->ov.hEvent = d->ev; + ResetEvent(d->ev); + if (ReadDirectoryChangesW(d->dir, d->buf, sizeof(d->buf), FALSE, CW_FILTER, &got, &d->ov, NULL) || + GetLastError() == ERROR_IO_PENDING) { + d->armed = 1; + } else { + d->armed = 0; + SD_LOGW(CW_LOG, "config watch: ReadDirectoryChangesW failed (%u)", (unsigned)GetLastError()); + } +} + +static void batch_add_path(cw_batch *b, const wchar_t *dir, const wchar_t *name, size_t nchars) +{ + size_t i; + if (b->npaths >= CW_MAX_PATHS || wcslen(dir) + nchars + 2 >= MAX_PATH) + return; + wcscpy_s(b->paths[b->npaths], MAX_PATH, dir); + wcscat_s(b->paths[b->npaths], MAX_PATH, L"\\"); + wcsncat_s(b->paths[b->npaths], MAX_PATH, name, nchars); + for (i = 0; i < b->npaths; i++) + if (wcscmp(b->paths[i], b->paths[b->npaths]) == 0) + return; /* the same file twice in one window */ + b->npaths++; +} + +/* Parses the FILE_NOTIFY_INFORMATION records of a completed read (bytes 0 = the buffer overflowed). */ +static void cw_parse(cw_dir *d, DWORD bytes, const wchar_t *dir, int is_root, cw_batch *b) +{ + DWORD off = 0; + if (bytes == 0) { + b->overflow = 1; /* everything may have changed */ + return; + } + for (;;) { + FILE_NOTIFY_INFORMATION *fi = (FILE_NOTIFY_INFORMATION *)(d->buf + off); + size_t nc = fi->FileNameLength / sizeof(wchar_t); + const wchar_t *nm = fi->FileName; + if (is_root) { + if (nc == 12 && _wcsnicmp(nm, L"settings.ini", 12) == 0) + b->settings = 1; + else if (nc == 8 && _wcsnicmp(nm, L"profiles", 8) == 0) + b->profiles_dir_added = 1; + } else if (nc > 4 && _wcsnicmp(nm + nc - 4, L".ini", 4) == 0) { + b->profiles = 1; + if (fi->Action != FILE_ACTION_REMOVED && fi->Action != FILE_ACTION_RENAMED_OLD_NAME) + batch_add_path(b, dir, nm, nc); + } + if (fi->NextEntryOffset == 0 || off + fi->NextEntryOffset >= sizeof(d->buf)) + break; + off += fi->NextEntryOffset; + } +} + +static void batch_flush(cw_batch *b) +{ + if (b->settings || b->overflow) { + const wchar_t *none[1]; + g_cw.cb(g_cw.ctx, "settings", none, 0); + } + if (b->profiles || b->overflow) { + const wchar_t *ps[CW_MAX_PATHS]; + uint32_t i; + for (i = 0; i < b->npaths; i++) + ps[i] = b->paths[i]; + g_cw.cb(g_cw.ctx, "profiles", ps, b->npaths); + } + memset(b, 0, sizeof(*b)); +} + +/* One read completed on `d` (root or profiles): parse it, re-arm, and note when the batch became dirty. */ +static void cw_on_read(cw_state *st, cw_dir *d, const wchar_t *dir, int is_root) +{ + DWORD got = 0; + int ok = GetOverlappedResult(d->dir, &d->ov, &got, FALSE) || GetLastError() == ERROR_MORE_DATA; + int before = st->batch.profiles_dir_added; + d->armed = 0; + if (ok) + cw_parse(d, got, dir, is_root, &st->batch); + if (is_root && ok && st->batch.profiles_dir_added != before && !st->prof.armed && + cw_open(&st->prof, g_cw.profiles)) + cw_arm(&st->prof); /* the profiles folder was created: watch it from now on */ + if (ok) + st->pending = 1; /* the flush emits only what the window really contained */ + st->last_event = sd_tick_ms(); + cw_arm(d); +} + +static DWORD WINAPI cw_thread(void *arg) +{ + cw_state *st; + SD_UNUSED(arg); + st = (cw_state *)sd_calloc(1, sizeof(*st)); + if (st == NULL) + return 0; + st->root.dir = INVALID_HANDLE_VALUE; + st->prof.dir = INVALID_HANDLE_VALUE; + if (!cw_open(&st->root, g_cw.root)) { + SD_LOGW(CW_LOG, "config watch: the config root cannot be opened; no config.changed events"); + sd_free(st); + return 0; + } + cw_arm(&st->root); + if (cw_open(&st->prof, g_cw.profiles)) + cw_arm(&st->prof); + st->last_try = sd_tick_ms(); + for (;;) { + HANDLE hs[3]; + DWORD n = 0, w; + DWORD wait = st->pending ? CW_DEBOUNCE_MS : 500; + hs[n++] = g_cw.stop; + if (st->root.armed) hs[n++] = st->root.ev; + if (st->prof.armed) hs[n++] = st->prof.ev; + w = WaitForMultipleObjects(n, hs, FALSE, wait); + if (w == WAIT_OBJECT_0 || w == WAIT_FAILED) + break; + if (w > WAIT_OBJECT_0 && w < WAIT_OBJECT_0 + n) { + HANDLE which = hs[w - WAIT_OBJECT_0]; + if (which == st->root.ev) + cw_on_read(st, &st->root, g_cw.root, 1); + else if (which == st->prof.ev) + cw_on_read(st, &st->prof, g_cw.profiles, 0); + } + if (st->pending && sd_tick_ms() - st->last_event >= CW_DEBOUNCE_MS) { + st->pending = 0; + batch_flush(&st->batch); + } + if (!st->prof.armed && sd_tick_ms() - st->last_try >= CW_RETRY_MS) { + st->last_try = sd_tick_ms(); + if (cw_open(&st->prof, g_cw.profiles)) + cw_arm(&st->prof); + } + } + cw_close(&st->root); + cw_close(&st->prof); + sd_free(st); + return 0; +} + +sd_err config_watch_start(config_watch_cb cb, void *ctx) +{ + wchar_t dir[MAX_PATH]; + DWORD attr; + if (cb == NULL) + return SD_E_INVALID_PARAM; + if (g_cw.thread) + return SD_OK; + if (sd_config_root(dir, SD_COUNTOF(dir)) != SD_OK) + return SD_E_NOT_FOUND; + attr = GetFileAttributesW(dir); + if (attr == INVALID_FILE_ATTRIBUTES || !(attr & FILE_ATTRIBUTE_DIRECTORY)) + return SD_E_NOT_FOUND; + wcscpy_s(g_cw.root, SD_COUNTOF(g_cw.root), dir); + if (sd_config_profiles_dir(dir, SD_COUNTOF(dir)) != SD_OK) + return SD_E_NOT_FOUND; + wcscpy_s(g_cw.profiles, SD_COUNTOF(g_cw.profiles), dir); + g_cw.cb = cb; + g_cw.ctx = ctx; + g_cw.stop = CreateEventW(NULL, TRUE, FALSE, NULL); + if (g_cw.stop == NULL) + return SD_E_NO_MEMORY; + g_cw.thread = CreateThread(NULL, 0, cw_thread, NULL, 0, NULL); + if (g_cw.thread == NULL) { + CloseHandle(g_cw.stop); + g_cw.stop = NULL; + return SD_E_NO_MEMORY; + } + return SD_OK; +} + +void config_watch_stop(void) +{ + if (g_cw.thread == NULL) + return; + SetEvent(g_cw.stop); + if (WaitForSingleObject(g_cw.thread, 5000) == WAIT_OBJECT_0) + CloseHandle(g_cw.thread); + else + SD_LOGW(CW_LOG, "config watch thread did not stop in time"); + g_cw.thread = NULL; + CloseHandle(g_cw.stop); + g_cw.stop = NULL; + g_cw.cb = NULL; +} diff --git a/apps/shelldrive-host/config_watch.h b/apps/shelldrive-host/config_watch.h new file mode 100644 index 0000000..0b5b211 --- /dev/null +++ b/apps/shelldrive-host/config_watch.h @@ -0,0 +1,22 @@ +/* Watch of the user's configuration (03 §2.4 config.changed, M3): the profiles folder and settings.ini of the + * user's config root, watched with ReadDirectoryChangesW on a thread of their own (XP-compatible, no + * post-XP import). Changes are debounced (500 ms of quiet) and reported once per batch through the callback. + * The watch never reloads anything: mounted profiles are not changed in flight; clients use config.reload. */ +#ifndef SD_HOST_CONFIG_WATCH_H +#define SD_HOST_CONFIG_WATCH_H + +#include +#include + +#include "sd_base/sd_err.h" + +/* what: "settings" or "profiles". For profiles, paths lists the changed profile files that still exist (the + * caller reads their ids); a removed file is not listed. Runs on the watch thread, once per debounced batch. */ +typedef void (*config_watch_cb)(void *ctx, const char *what, const wchar_t *const *paths, uint32_t npaths); + +/* Starts the watch thread. A missing config root is SD_E_NOT_FOUND (the agent then runs without the watch). */ +sd_err config_watch_start(config_watch_cb cb, void *ctx); +/* Stops the thread and waits for it (up to 5 s). Safe when it was never started. */ +void config_watch_stop(void); + +#endif /* SD_HOST_CONFIG_WATCH_H */ diff --git a/apps/shelldrive-host/explorer_data.c b/apps/shelldrive-host/explorer_data.c new file mode 100644 index 0000000..cf33438 --- /dev/null +++ b/apps/shelldrive-host/explorer_data.c @@ -0,0 +1,928 @@ +/* Explorer data written by the agent (03 §8.3, §8.7; M3). See explorer_data.h. + * + * Per user, under HKCU: the drive icon of every drive this agent maps + * \\DefaultIcon = "",-1 + * (default = Software\Classes\Applications\Explorer.exe\Drives, 03 §8.3 [verify on 7/10/11]). + * The letters it wrote are listed in state\explorer.ini ([icons] letters) so that a crashed agent's icons + * are removed at the next start. An icon is removed only when its DefaultIcon still points at our binary. + * + * Network locations (03 §8.7): one folder per profile in the NetHood folder, with a read-only attribute, + * desktop.ini (folder class + a [ShellDrive] profile= marker we recognize) and target.lnk. The target is + * the placeholder folder of the profile while the drive is not mapped and the drive root while it is. + * + * XP-compatible: SHChangeNotify, SHGetFolderPathW, CoCreateInstance (IShellLinkW) and MoveFileExW are on + * the XP floor (tests/os-imports/xp-sp3-x86.txt). No Vista+ API is used. */ +#include +#define INITGUID +#include +#include +#include +#include +#include +#include + +#include "explorer_data.h" +#include "sd_base/sd_mem.h" +#include "sd_config/sd_config.h" +#include "sd_policy/sd_policy.h" +#include "sd_config/sd_ini.h" +#include "sd_platform/sd_log.h" +#include "sd_base/sd_buf.h" + +#define EX_LOG "explorer" +#define ICON_KEY_DEFAULT L"Software\\Classes\\Applications\\Explorer.exe\\Drives" +#define NETHOOD_CLSID L"{0AFACED1-E828-11D1-9187-B532F1E9575D}" +#define UI_EXE_NAME L"shelldrive-ui.exe" +#define ICON_INDEX 1 /* - of DefaultIcon / IconResource: the UI's icon resource */ +#define PROFILE_MAX 256u +#define PROFILE_NAME_CCH 128u +#define NAME_CCH 96u + +typedef struct ex_profile { + char id[33]; + char name[PROFILE_NAME_CCH]; + int network_location; + int scope; +} ex_profile; + +static int g_inited; +static int g_icons_on; /* icons and the explorer.ini list */ +static int g_netloc_on; /* Network location folders */ +static int g_test; +static wchar_t g_icon_key[256]; /* HKCU subkey of the drive icons */ +static wchar_t g_nethood_test[MAX_PATH]; /* test override of the NetHood folder */ +static wchar_t g_ui_path[MAX_PATH]; /* \shelldrive-ui.exe */ +static int g_active[26]; /* drives this agent shows as present */ +static char g_loc_id[26][33]; /* profile id of the Network location mounted on each letter, "" = none */ + +/* ---- small helpers ---- */ + +static void copy_w(wchar_t *dst, size_t cch, const wchar_t *src) +{ + wcscpy_s(dst, cch, src); +} + +static int cat_w(wchar_t *dst, size_t cch, const wchar_t *src) +{ + return wcscat_s(dst, cch, src) == 0; +} + +static void utf8_to_wide(const char *s, wchar_t *out, size_t cch) +{ + out[0] = 0; + if (s == NULL || cch == 0) + return; + if (MultiByteToWideChar(CP_UTF8, 0, s, -1, out, (int)cch) == 0) + out[0] = 0; + out[cch - 1] = 0; +} + +static void wide_to_ascii(const wchar_t *w, char *out, size_t cch) +{ + size_t i; + for (i = 0; i + 1 < cch && w[i]; i++) + out[i] = (w[i] < 0x80) ? (char)w[i] : '?'; + out[i] = 0; +} + +static int valid_id(const char *id) +{ + size_t i; + if (strlen(id) != 32) + return 0; + for (i = 0; i < 32; i++) + if (!((id[i] >= '0' && id[i] <= '9') || (id[i] >= 'a' && id[i] <= 'f'))) + return 0; + return 1; +} + +/* Reserved characters and control characters become '_'; trailing dots and spaces are trimmed; an empty + result is "ShellDrive"; at most NAME_CCH - 1 units (03 §8.7). */ +static void sanitize_name(const wchar_t *in, wchar_t *out, size_t cch) +{ + size_t i, n = 0; + for (i = 0; in[i] && n + 1 < cch && n < NAME_CCH - 1; i++) { + wchar_t c = in[i]; + if (c < 0x20 || wcschr(L"\\/:*?\"<>|", c) != NULL) + c = L'_'; + out[n++] = c; + } + while (n > 0 && (out[n - 1] == L'.' || out[n - 1] == L' ')) + n--; + if (n == 0) { + copy_w(out, cch, L"ShellDrive"); + return; + } + out[n] = 0; +} + +/* Local per-user root: %LOCALAPPDATA%\LibreStatic\ShellDrive (03 §3.2). Test builds with the test + variables use the test configuration root instead. */ +static int local_root(wchar_t *out, size_t cch) +{ +#ifdef SD_HOST_TEST_HOOKS + if (g_test) + return sd_config_root(out, cch) == SD_OK; +#endif + if (SHGetFolderPathW(NULL, CSIDL_LOCAL_APPDATA, NULL, SHGFP_TYPE_CURRENT, out) != S_OK) + return 0; + return cat_w(out, cch, L"\\LibreStatic\\ShellDrive"); +} + +static int mkdirs(const wchar_t *path) +{ + wchar_t buf[MAX_PATH]; + size_t i; + copy_w(buf, SD_COUNTOF(buf), path); + for (i = 1; buf[i]; i++) { + if (buf[i] == L'\\' && i > 2 && buf[i - 1] != L':') { + buf[i] = 0; + CreateDirectoryW(buf, NULL); + buf[i] = L'\\'; + } + } + CreateDirectoryW(buf, NULL); + return GetFileAttributesW(path) != INVALID_FILE_ATTRIBUTES; +} + +static int write_file(const wchar_t *path, const char *data, size_t len) +{ + HANDLE h = CreateFileW(path, GENERIC_WRITE, 0, NULL, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL); + DWORD wrote = 0; + int ok; + if (h == INVALID_HANDLE_VALUE) + return 0; + ok = WriteFile(h, data, (DWORD)len, &wrote, NULL) && wrote == len; + CloseHandle(h); + return ok; +} + +/* ---- roots and policy ---- */ + +static int nethood_path(wchar_t *out, size_t cch) +{ +#ifdef SD_HOST_TEST_HOOKS + if (g_test) { + copy_w(out, cch, g_nethood_test); + return out[0] != 0; + } +#endif + (void)cch; /* callers pass MAX_PATH buffers, which SHGetFolderPathW needs */ + return SHGetFolderPathW(NULL, CSIDL_NETHOOD, NULL, SHGFP_TYPE_CURRENT, out) == S_OK; +} + +/* Policy DisableNetworkLocations = 1 (HKLM wins over HKCU, 03 §3.5; read through sd_policy). */ +static int policy_disables_netloc(void) +{ + DWORD v = 0; + return sd_policy_get_dword(SD_POLICY_DISABLENETWORKLOCATIONS, &v) && v != 0; +} + +void explorer_init(void) +{ + wchar_t dir[MAX_PATH]; + DWORD n; + size_t i; + if (g_inited) + return; + g_inited = 1; + n = GetModuleFileNameW(NULL, dir, MAX_PATH); + if (n > 0 && n < MAX_PATH) { + for (i = wcslen(dir); i > 0 && dir[i - 1] != L'\\'; i--) + ; + dir[i] = 0; + copy_w(g_ui_path, SD_COUNTOF(g_ui_path), dir); + cat_w(g_ui_path, SD_COUNTOF(g_ui_path), UI_EXE_NAME); + } + copy_w(g_icon_key, SD_COUNTOF(g_icon_key), ICON_KEY_DEFAULT); + g_icons_on = 1; + g_netloc_on = !policy_disables_netloc(); +#ifdef SD_HOST_TEST_HOOKS + /* tests: nothing in the user's keys or NetHood unless a test variable names a test location */ + { + wchar_t v[MAX_PATH]; + g_test = 1; + g_icons_on = 0; + g_netloc_on = 0; + if (GetEnvironmentVariableW(L"SHELLDRIVE_TEST_EXPLORER_KEY", v, SD_COUNTOF(v)) - 1u < SD_COUNTOF(v) - 1u) { + copy_w(g_icon_key, SD_COUNTOF(g_icon_key), v); + g_icons_on = 1; + } + if (GetEnvironmentVariableW(L"SHELLDRIVE_TEST_NETHOOD", g_nethood_test, SD_COUNTOF(g_nethood_test)) - 1u < + SD_COUNTOF(g_nethood_test) - 1u) + g_netloc_on = 1; + else + g_nethood_test[0] = 0; + } +#endif +} + +/* ---- drive icons (03 §8.3) ---- */ + +static void icon_key_path(wchar_t letter, wchar_t *out, size_t cch) +{ + _snwprintf_s(out, cch, _TRUNCATE, L"%s\\%lc", g_icon_key, (wint_t)letter); +} + +static void icon_default_value(wchar_t *out, size_t cch) +{ + _snwprintf_s(out, cch, _TRUNCATE, L"\"%s\",-%d", g_ui_path, ICON_INDEX); +} + +/* 1 when the DefaultIcon key exists and its value does not point at our binary (never overwritten). */ +static int icon_foreign(const wchar_t *sub) +{ + wchar_t cur[MAX_PATH + 32], mine[MAX_PATH + 8]; + HKEY hk; + DWORD t = 0, sz = sizeof(cur); + int foreign = 0; + if (RegOpenKeyExW(HKEY_CURRENT_USER, sub, 0, KEY_QUERY_VALUE, &hk) != ERROR_SUCCESS) + return 0; + if (RegQueryValueExW(hk, NULL, NULL, &t, (LPBYTE)cur, &sz) == ERROR_SUCCESS && t == REG_SZ) { + cur[SD_COUNTOF(cur) - 1] = 0; + _snwprintf_s(mine, SD_COUNTOF(mine), _TRUNCATE, L"\"%s\"", g_ui_path); + foreign = wcsncmp(cur, mine, wcslen(mine)) != 0; + } else { + foreign = 1; + } + RegCloseKey(hk); + return foreign; +} + +static int icon_set(wchar_t letter) +{ + wchar_t key[300], value[MAX_PATH + 16], sub[320]; + HKEY hk; + LONG rc; + icon_key_path(letter, key, SD_COUNTOF(key)); + _snwprintf_s(sub, SD_COUNTOF(sub), _TRUNCATE, L"%s\\DefaultIcon", key); + icon_default_value(value, SD_COUNTOF(value)); + rc = RegCreateKeyExW(HKEY_CURRENT_USER, sub, 0, NULL, 0, KEY_SET_VALUE, NULL, &hk, NULL); + if (rc != ERROR_SUCCESS) { + SD_LOGW(EX_LOG, "drive icon key for %c: %d", (int)letter, (int)rc); + return 0; + } + rc = RegSetValueExW(hk, NULL, 0, REG_SZ, (const BYTE *)value, (DWORD)((wcslen(value) + 1) * sizeof(wchar_t))); + RegCloseKey(hk); + return rc == ERROR_SUCCESS; +} + +/* Removes the icon only when its DefaultIcon is ours (starts with our binary), never someone else's. */ +static void icon_remove(wchar_t letter) +{ + wchar_t key[300], sub[320], cur[MAX_PATH + 32], mine[MAX_PATH + 8]; + HKEY hk; + DWORD t = 0, sz = sizeof(cur); + int ours = 0; + icon_key_path(letter, key, SD_COUNTOF(key)); + _snwprintf_s(sub, SD_COUNTOF(sub), _TRUNCATE, L"%s\\DefaultIcon", key); + if (RegOpenKeyExW(HKEY_CURRENT_USER, sub, 0, KEY_QUERY_VALUE, &hk) == ERROR_SUCCESS) { + if (RegQueryValueExW(hk, NULL, NULL, &t, (LPBYTE)cur, &sz) == ERROR_SUCCESS && t == REG_SZ) { + cur[SD_COUNTOF(cur) - 1] = 0; + _snwprintf_s(mine, SD_COUNTOF(mine), _TRUNCATE, L"\"%s\"", g_ui_path); + ours = wcsncmp(cur, mine, wcslen(mine)) == 0; + } + RegCloseKey(hk); + if (!ours) + return; + RegDeleteKeyW(HKEY_CURRENT_USER, sub); + RegDeleteKeyW(HKEY_CURRENT_USER, key); + } +} + +/* ---- explorer.ini: the letters with an icon of ours ---- */ + +static int ini_path(wchar_t *out, size_t cch) +{ + wchar_t root[MAX_PATH]; + if (!local_root(root, SD_COUNTOF(root))) + return 0; + _snwprintf_s(out, cch, _TRUNCATE, L"%s\\state", root); + if (!mkdirs(out)) + return 0; + _snwprintf_s(out, cch, _TRUNCATE, L"%s\\state\\explorer.ini", root); + return 1; +} + +static sd_err ini_load(sd_ini **ini) +{ + wchar_t path[MAX_PATH]; + HANDLE h; + DWORD size, got = 0; + char *buf = NULL; + sd_err e; + uint32_t flags = 0; + *ini = NULL; + if (!ini_path(path, SD_COUNTOF(path))) + return SD_E_NOT_FOUND; + h = CreateFileW(path, GENERIC_READ, FILE_SHARE_READ, NULL, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL); + if (h == INVALID_HANDLE_VALUE) + return sd_ini_new(ini); + size = GetFileSize(h, NULL); + if (size == INVALID_FILE_SIZE || size > 65536) { + CloseHandle(h); + return sd_ini_new(ini); + } + buf = (char *)sd_alloc(size + 1u); + if (buf == NULL || !ReadFile(h, buf, size, &got, NULL)) { + CloseHandle(h); + sd_free(buf); + return sd_ini_new(ini); + } + CloseHandle(h); + buf[got] = 0; + e = sd_ini_parse(buf, got, ini, &flags); + sd_free(buf); + if (e != SD_OK) + return sd_ini_new(ini); + return SD_OK; +} + +static void ini_save(sd_ini *ini) +{ + wchar_t path[MAX_PATH]; + sd_wr w; + if (!ini_path(path, SD_COUNTOF(path))) + return; + sd_wr_init(&w, 4096); + if (sd_ini_serialize(ini, &w) == SD_OK && sd_wr_ok(&w)) + (void)write_file(path, (const char *)w.p, w.len); + sd_wr_free(&w); +} + +/* The letters list is one key: [icons] letters = DGH (uppercase letters). */ +static void ini_set_letters(const char *letters) +{ + sd_ini *ini = NULL; + if (ini_load(&ini) != SD_OK || ini == NULL) + return; + (void)sd_ini_set(ini, "icons", "letters", letters); + ini_save(ini); + sd_ini_free(ini); +} + +static void ini_add_letter(wchar_t letter) +{ + sd_ini *ini = NULL; + const char *cur; + char next[32]; + size_t n = 0; + if (ini_load(&ini) != SD_OK || ini == NULL) + return; + cur = sd_ini_get(ini, "icons", "letters"); + if (cur) { + while (cur[n] && n + 2 < sizeof(next)) { + next[n] = cur[n]; + n++; + } + } + if (!(cur && strchr(cur, (char)letter) != NULL) && n + 2 < sizeof(next)) { + next[n++] = (char)letter; + next[n] = 0; + (void)sd_ini_set(ini, "icons", "letters", next); + ini_save(ini); + } + sd_ini_free(ini); +} + +static void ini_remove_letter(wchar_t letter) +{ + sd_ini *ini = NULL; + const char *cur; + char next[32]; + size_t n = 0, i; + if (ini_load(&ini) != SD_OK || ini == NULL) + return; + cur = sd_ini_get(ini, "icons", "letters"); + if (cur) { + for (i = 0; cur[i] && n + 1 < sizeof(next); i++) + if (cur[i] != (char)letter) + next[n++] = cur[i]; + next[n] = 0; + (void)sd_ini_set(ini, "icons", "letters", next); + ini_save(ini); + } + sd_ini_free(ini); +} + +/* ---- Network location entries (03 §8.7) ---- */ + +/* The [ShellDrive] profile= marker of a NetHood folder, "" when the folder is not ours. */ +static int folder_marker(const wchar_t *folder, char *id, size_t cch) +{ + wchar_t ini[MAX_PATH], wid[64]; + copy_w(ini, SD_COUNTOF(ini), folder); + cat_w(ini, SD_COUNTOF(ini), L"\\desktop.ini"); + id[0] = 0; + if (GetPrivateProfileStringW(L"ShellDrive", L"profile", L"", wid, SD_COUNTOF(wid), ini) == 0) + return 0; + wide_to_ascii(wid, id, cch); + return id[0] != 0; +} + +/* Finds the NetHood folder of a profile id. */ +static int find_entry(const wchar_t *nethood, const char *id, wchar_t *folder, size_t cch) +{ + WIN32_FIND_DATAW fd; + HANDLE h; + wchar_t pattern[MAX_PATH]; + int found = 0; + _snwprintf_s(pattern, SD_COUNTOF(pattern), _TRUNCATE, L"%s\\*", nethood); + h = FindFirstFileW(pattern, &fd); + if (h == INVALID_HANDLE_VALUE) + return 0; + do { + char mid[33]; + if (!(fd.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) || wcscmp(fd.cFileName, L".") == 0 || + wcscmp(fd.cFileName, L"..") == 0) + continue; + _snwprintf_s(folder, cch, _TRUNCATE, L"%s\\%s", nethood, fd.cFileName); + if (folder_marker(folder, mid, sizeof(mid)) && strcmp(mid, id) == 0) { + found = 1; + break; + } + } while (FindNextFileW(h, &fd)); + FindClose(h); + return found; +} + +/* A folder whose name is the wanted name, or the wanted name with a " (n)" suffix (collision). */ +static int leaf_matches(const wchar_t *leaf, const wchar_t *want) +{ + size_t n = wcslen(want); + if (wcscmp(leaf, want) == 0) + return 1; + return wcsncmp(leaf, want, n) == 0 && leaf[n] == L' ' && leaf[n + 1] == L'('; +} + +/* Deletes an entry folder of ours: desktop.ini, target.lnk and the folder. Only called after the marker + check. */ +static void delete_entry(const wchar_t *folder) +{ + WIN32_FIND_DATAW fd; + HANDLE h; + wchar_t pattern[MAX_PATH], path[MAX_PATH]; + SetFileAttributesW(folder, FILE_ATTRIBUTE_NORMAL); + _snwprintf_s(pattern, SD_COUNTOF(pattern), _TRUNCATE, L"%s\\*", folder); + h = FindFirstFileW(pattern, &fd); + if (h != INVALID_HANDLE_VALUE) { + do { + if (fd.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) + continue; + _snwprintf_s(path, SD_COUNTOF(path), _TRUNCATE, L"%s\\%s", folder, fd.cFileName); + SetFileAttributesW(path, FILE_ATTRIBUTE_NORMAL); + DeleteFileW(path); + } while (FindNextFileW(h, &fd)); + FindClose(h); + } + RemoveDirectoryW(folder); +} + +/* Placeholder folder of a profile: %LOCALAPPDATA%\LibreStatic\ShellDrive\places\\ (03 §8.7). */ +static int placeholder_dir(const char *id, wchar_t *out, size_t cch) +{ + wchar_t root[MAX_PATH], wid[40]; + size_t i; + if (!local_root(root, SD_COUNTOF(root))) + return 0; + _snwprintf_s(out, cch, _TRUNCATE, L"%s\\places", root); + if (!mkdirs(out)) + return 0; + for (i = 0; id[i] && i + 1 < SD_COUNTOF(wid); i++) + wid[i] = (wchar_t)id[i]; + wid[i] = 0; + _snwprintf_s(out, cch, _TRUNCATE, L"%s\\places\\%s", root, wid); + return mkdirs(out); +} + +/* Writes the placeholder file "Connect to .sdmount" (profile = , 8.1 grammar) into the + placeholder folder and returns the folder. */ +static int ensure_placeholder(const char *id, const wchar_t *want, wchar_t *dir, size_t cch) +{ + wchar_t file[MAX_PATH]; + char text[64]; + if (!placeholder_dir(id, dir, cch)) + return 0; + _snwprintf_s(file, SD_COUNTOF(file), _TRUNCATE, L"%s\\Connect to %s.sdmount", dir, want); + _snprintf_s(text, sizeof(text), _TRUNCATE, "profile = %s\r\n", id); + return write_file(file, text, strlen(text)); +} + +static void remove_placeholder(const char *id) +{ + wchar_t dir[MAX_PATH], pattern[MAX_PATH], path[MAX_PATH]; + WIN32_FIND_DATAW fd; + HANDLE h; + if (!placeholder_dir(id, dir, SD_COUNTOF(dir))) + return; + _snwprintf_s(pattern, SD_COUNTOF(pattern), _TRUNCATE, L"%s\\*.sdmount", dir); + h = FindFirstFileW(pattern, &fd); + if (h != INVALID_HANDLE_VALUE) { + do { + _snwprintf_s(path, SD_COUNTOF(path), _TRUNCATE, L"%s\\%s", dir, fd.cFileName); + DeleteFileW(path); + } while (FindNextFileW(h, &fd)); + FindClose(h); + } + RemoveDirectoryW(dir); +} + +/* Target of the entry: the drive root when the drive is mapped, the placeholder folder otherwise. Writes + target.lnk through IShellLinkW and a temp file moved over the old link (03 §8.7). */ +static int write_link(const wchar_t *folder, const wchar_t *target) +{ + HRESULT hr; + int inited; + IShellLinkW *sl = NULL; + IPersistFile *pf = NULL; + wchar_t tmp[MAX_PATH], fin[MAX_PATH]; + int ok = 0; + hr = CoInitializeEx(NULL, COINIT_APARTMENTTHREADED); + inited = SUCCEEDED(hr); + if (CoCreateInstance(&CLSID_ShellLink, NULL, CLSCTX_INPROC_SERVER, &IID_IShellLinkW, (void **)&sl) == S_OK) { + if (sl->lpVtbl->SetPath(sl, target) == S_OK && + sl->lpVtbl->QueryInterface(sl, &IID_IPersistFile, (void **)&pf) == S_OK) { + _snwprintf_s(tmp, SD_COUNTOF(tmp), _TRUNCATE, L"%s\\target.lnk.tmp", folder); + _snwprintf_s(fin, SD_COUNTOF(fin), _TRUNCATE, L"%s\\target.lnk", folder); + if (pf->lpVtbl->Save(pf, tmp, TRUE) == S_OK) + ok = MoveFileExW(tmp, fin, MOVEFILE_REPLACE_EXISTING) != 0; + pf->lpVtbl->Release(pf); + } + sl->lpVtbl->Release(sl); + } + if (inited) + CoUninitialize(); + return ok; +} + +/* Creates the entry folder with its desktop.ini, read-only attribute and link. */ +static int create_entry(const wchar_t *nethood, const char *id, const wchar_t *want, const wchar_t *target, + wchar_t *folder, size_t cch) +{ + wchar_t ini[MAX_PATH], icon[MAX_PATH + 8], base[MAX_PATH]; + int n; + for (n = 1; n <= 9; n++) { + if (n == 1) + _snwprintf_s(base, SD_COUNTOF(base), _TRUNCATE, L"%s\\%s", nethood, want); + else + _snwprintf_s(base, SD_COUNTOF(base), _TRUNCATE, L"%s\\%s (%d)", nethood, want, n); + if (GetFileAttributesW(base) == INVALID_FILE_ATTRIBUTES) + break; + } + if (n > 9) + return 0; + if (!CreateDirectoryW(base, NULL)) + return 0; + copy_w(folder, cch, base); + copy_w(ini, SD_COUNTOF(ini), base); + cat_w(ini, SD_COUNTOF(ini), L"\\desktop.ini"); + WritePrivateProfileStringW(L".ShellClassInfo", L"CLSID2", NETHOOD_CLSID, ini); + WritePrivateProfileStringW(L".ShellClassInfo", L"Flags", L"2", ini); + _snwprintf_s(icon, SD_COUNTOF(icon), _TRUNCATE, L"%s,-%d", g_ui_path, ICON_INDEX); + WritePrivateProfileStringW(L".ShellClassInfo", L"IconResource", icon, ini); + { + wchar_t wid[40]; + size_t i; + for (i = 0; id[i] && i + 1 < SD_COUNTOF(wid); i++) + wid[i] = (wchar_t)id[i]; + wid[i] = 0; + WritePrivateProfileStringW(L"ShellDrive", L"profile", wid, ini); + } + SetFileAttributesW(ini, FILE_ATTRIBUTE_HIDDEN | FILE_ATTRIBUTE_SYSTEM); + (void)write_link(base, target); + SetFileAttributesW(base, FILE_ATTRIBUTE_READONLY); /* desktop.ini is honoured on read-only folders */ + return 1; +} + +/* Brings the entry of one profile to the wanted state. relink = 0 leaves the target of an existing entry alone + (the CLI does not know whether the profile is mounted). created (may be NULL) gets 1 when the entry was made. + Returns 1 when the entry is in place, 0 when it could not be written. */ +static int netloc_sync(const char *id, const char *name, wchar_t letter, int present, int relink, int *created) +{ + wchar_t nethood[MAX_PATH], wname[PROFILE_NAME_CCH], want[NAME_CCH], folder[MAX_PATH], placeholder[MAX_PATH]; + wchar_t target[MAX_PATH]; + int found; + if (created) + *created = 0; + if (!nethood_path(nethood, SD_COUNTOF(nethood)) || !mkdirs(nethood)) + return 0; + utf8_to_wide(name, wname, SD_COUNTOF(wname)); + if (wname[0] == 0) + copy_w(wname, SD_COUNTOF(wname), L"ShellDrive"); + sanitize_name(wname, want, SD_COUNTOF(want)); + if (!ensure_placeholder(id, want, placeholder, SD_COUNTOF(placeholder))) + return 0; + if (present && letter >= L'A' && letter <= L'Z') { + target[0] = letter; + target[1] = L':'; + target[2] = L'\\'; + target[3] = 0; + } else { + copy_w(target, SD_COUNTOF(target), placeholder); + } + found = find_entry(nethood, id, folder, SD_COUNTOF(folder)); + if (found) { + const wchar_t *leaf = wcsrchr(folder, L'\\'); + if (!leaf || !leaf_matches(leaf + 1, want)) { + delete_entry(folder); + found = 0; + } + } + if (!found) { + if (!create_entry(nethood, id, want, target, folder, SD_COUNTOF(folder))) + return 0; + if (created) + *created = 1; + } else if (relink) { + (void)write_link(folder, target); + } + SHChangeNotify(SHCNE_UPDATEDIR, SHCNF_PATHW, folder, NULL); + return 1; +} + +/* ---- control thread entry points ---- */ + +void explorer_apply(const host_explorer_mount *m) +{ + int idx; + if (m == NULL || m->letter < L'A' || m->letter > L'Z') + return; + idx = m->letter - L'A'; + if (g_icons_on) { + if (m->present && !g_active[idx]) { + if (icon_set(m->letter)) { + wchar_t root[MAX_PATH]; + ini_add_letter(m->letter); + _snwprintf_s(root, SD_COUNTOF(root), _TRUNCATE, L"%lc:\\", (wint_t)m->letter); + SHChangeNotify(SHCNE_DRIVEADD, SHCNF_PATHW, root, NULL); + SHChangeNotify(SHCNE_UPDATEITEM, SHCNF_PATHW, root, NULL); + g_active[idx] = 1; + } + } else if (!m->present && g_active[idx]) { + wchar_t root[MAX_PATH]; + icon_remove(m->letter); + ini_remove_letter(m->letter); + _snwprintf_s(root, SD_COUNTOF(root), _TRUNCATE, L"%lc:\\", (wint_t)m->letter); + SHChangeNotify(SHCNE_DRIVEREMOVED, SHCNF_PATHW, root, NULL); + g_active[idx] = 0; + } + } + if (g_netloc_on && m->profile_id[0] && m->network_location) { + if (m->present) + strncpy_s(g_loc_id[idx], sizeof(g_loc_id[idx]), m->profile_id, _TRUNCATE); + else + g_loc_id[idx][0] = 0; + } else { + g_loc_id[idx][0] = 0; + } + if (g_netloc_on && m->profile_id[0] && m->network_location) + (void)netloc_sync(m->profile_id, m->profile_name, m->letter, m->present, 1, NULL); +} + +/* A profile gets a Network location when it asks for one and is not a service profile (03 §8.7). */ +static int netloc_wanted(const ex_profile *p) +{ + return p->network_location && p->scope != SD_PROFILE_SCOPE_SERVICE; +} + +/* Letter of the mapped drive of a profile, 0 when it is not mapped. */ +static wchar_t loc_letter(const char *id) +{ + int i; + for (i = 0; i < 26; i++) + if (g_loc_id[i][0] && strcmp(g_loc_id[i], id) == 0) + return (wchar_t)(L'A' + i); + return 0; +} + +typedef struct ex_collect { + ex_profile *items; + unsigned n; +} ex_collect; + +static int collect_profile(void *ctx, const sd_profile *p, const wchar_t *path) +{ + ex_collect *c = (ex_collect *)ctx; + SD_UNUSED(path); + if (c->n < PROFILE_MAX) { + ex_profile *x = &c->items[c->n++]; + memset(x, 0, sizeof(*x)); + strncpy_s(x->id, sizeof(x->id), p->id, _TRUNCATE); + strncpy_s(x->name, sizeof(x->name), p->name, _TRUNCATE); + x->network_location = p->network_location; + x->scope = p->scope; + } + return 1; +} + +/* Reconciles the Network location folders with the profiles (03 §8.7, lifecycle): folders with our marker whose + profile is gone or no longer asks for one are deleted; every profile that asks for one gets its folder back. + Entries without our marker are never touched. relink = 0 keeps the target of existing entries (CLI repair). + *changed (may be NULL) gets the number of entries deleted plus created. Returns 0 when the NetHood folder + is not available. */ +static int netloc_reconcile_run(int relink, int *changed) +{ + ex_collect c; + wchar_t nethood[MAX_PATH], pattern[MAX_PATH]; + WIN32_FIND_DATAW fd; + HANDLE h; + unsigned i; + int ok = 0; + if (changed) + *changed = 0; + memset(&c, 0, sizeof(c)); + c.items = (ex_profile *)sd_calloc(PROFILE_MAX, sizeof(ex_profile)); + if (c.items == NULL) + return 0; + (void)sd_profile_enum(collect_profile, &c); + if (!nethood_path(nethood, SD_COUNTOF(nethood)) || !mkdirs(nethood)) + goto done; + + _snwprintf_s(pattern, SD_COUNTOF(pattern), _TRUNCATE, L"%s\\*", nethood); + h = FindFirstFileW(pattern, &fd); + if (h != INVALID_HANDLE_VALUE) { + do { + wchar_t folder[MAX_PATH]; + char mid[33]; + int keep = 0; + if (!(fd.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) || wcscmp(fd.cFileName, L".") == 0 || + wcscmp(fd.cFileName, L"..") == 0) + continue; + _snwprintf_s(folder, SD_COUNTOF(folder), _TRUNCATE, L"%s\\%s", nethood, fd.cFileName); + if (!folder_marker(folder, mid, sizeof(mid))) + continue; + for (i = 0; i < c.n; i++) + if (strcmp(c.items[i].id, mid) == 0 && netloc_wanted(&c.items[i])) + keep = 1; + if (!keep) { + delete_entry(folder); + remove_placeholder(mid); + if (changed) + (*changed)++; + } + } while (FindNextFileW(h, &fd)); + FindClose(h); + } + for (i = 0; i < c.n; i++) + if (netloc_wanted(&c.items[i]) && valid_id(c.items[i].id)) { + wchar_t l = loc_letter(c.items[i].id); /* a mounted profile keeps pointing at its drive */ + int created = 0; + (void)netloc_sync(c.items[i].id, c.items[i].name, l, l != 0, relink, &created); + if (changed && created) + (*changed)++; + } + SHChangeNotify(SHCNE_UPDATEDIR, SHCNF_PATHW, nethood, NULL); + ok = 1; + +done: + sd_free(c.items); + return ok; +} + +static void netloc_reconcile(void) +{ + (void)netloc_reconcile_run(1, NULL); +} + +/* Start: removes the icons listed by a crashed agent, then reconciles the Network locations. */ +void explorer_reconcile_start(void) +{ + sd_ini *ini = NULL; + if (g_icons_on && ini_load(&ini) == SD_OK && ini != NULL) { + const char *letters = sd_ini_get(ini, "icons", "letters"); + if (letters) { + size_t i; + for (i = 0; letters[i]; i++) { + wchar_t l = (wchar_t)(unsigned char)letters[i]; + if (l >= L'A' && l <= L'Z') + icon_remove(l); + } + } + sd_ini_free(ini); + ini_set_letters(""); + } else if (ini) { + sd_ini_free(ini); + } + if (g_netloc_on) + netloc_reconcile(); +} + +/* Shutdown: every icon this agent wrote is removed (the mounts are gone by now). */ +void explorer_shutdown(void) +{ + sd_ini *ini = NULL; + int i; + if (!g_icons_on) + return; + if (ini_load(&ini) == SD_OK && ini != NULL) { + const char *letters = sd_ini_get(ini, "icons", "letters"); + if (letters) { + size_t k; + for (k = 0; letters[k]; k++) { + wchar_t l = (wchar_t)(unsigned char)letters[k]; + if (l >= L'A' && l <= L'Z') + icon_remove(l); + } + } + sd_ini_free(ini); + } + ini_set_letters(""); + for (i = 0; i < 26; i++) + g_active[i] = 0; +} + +/* The profiles changed while the agent runs (config watch): entries are created, renamed and removed to match, + mounted ones keep pointing at their drive. Control thread. */ +void explorer_config_changed(void) +{ + if (g_netloc_on) + netloc_reconcile(); +} + +/* User uninstall (--installer-hook user-cleanup, 03 §8.7): every entry of ours in NetHood, the placeholder + folders and any drive icon left in explorer.ini go; foreign entries stay. Works without an agent. */ +void explorer_cleanup_user(void) +{ + wchar_t nethood[MAX_PATH], pattern[MAX_PATH]; + WIN32_FIND_DATAW fd; + HANDLE h; + explorer_init(); + if ((!g_test || g_netloc_on) && nethood_path(nethood, SD_COUNTOF(nethood))) { + _snwprintf_s(pattern, SD_COUNTOF(pattern), _TRUNCATE, L"%s\\*", nethood); + h = FindFirstFileW(pattern, &fd); + if (h != INVALID_HANDLE_VALUE) { + do { + wchar_t folder[MAX_PATH]; + char mid[33]; + if (!(fd.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) || wcscmp(fd.cFileName, L".") == 0 || + wcscmp(fd.cFileName, L"..") == 0) + continue; + _snwprintf_s(folder, SD_COUNTOF(folder), _TRUNCATE, L"%s\\%s", nethood, fd.cFileName); + if (folder_marker(folder, mid, sizeof(mid))) { + delete_entry(folder); + remove_placeholder(mid); + } + } while (FindNextFileW(h, &fd)); + FindClose(h); + } + SHChangeNotify(SHCNE_UPDATEDIR, SHCNF_PATHW, nethood, NULL); + } + if (g_icons_on) + explorer_shutdown(); +} + +/* ---- shelldrive location verbs (03 §6.1, §8.7): shelldrive.exe, no agent ---- */ + +int explorer_location_blocked(void) +{ + explorer_init(); + return policy_disables_netloc(); +} + +int explorer_location_add(const char *id, const char *name, int *created) +{ + if (created) + *created = 0; + explorer_init(); + if (policy_disables_netloc()) + return EXPLORER_LOC_POLICY; + if (!valid_id(id)) + return EXPLORER_LOC_FAILED; + return netloc_sync(id, name, 0, 0, 0, created) ? EXPLORER_LOC_OK : EXPLORER_LOC_FAILED; +} + +int explorer_location_remove(const char *id, int *removed) +{ + wchar_t nethood[MAX_PATH], folder[MAX_PATH]; + if (removed) + *removed = 0; + explorer_init(); + if (policy_disables_netloc()) + return EXPLORER_LOC_POLICY; + if (!valid_id(id) || !nethood_path(nethood, SD_COUNTOF(nethood))) + return EXPLORER_LOC_FAILED; + if (find_entry(nethood, id, folder, SD_COUNTOF(folder))) { /* find_entry checks the marker */ + delete_entry(folder); + if (removed) + *removed = 1; + } + remove_placeholder(id); + SHChangeNotify(SHCNE_UPDATEDIR, SHCNF_PATHW, nethood, NULL); + return EXPLORER_LOC_OK; +} + +int explorer_location_repair(int *changed) +{ + HANDLE lock = NULL; + int ok; + if (changed) + *changed = 0; + explorer_init(); + if (policy_disables_netloc()) + return EXPLORER_LOC_POLICY; + if (sd_config_lock(&lock, 5000) != SD_OK) + return EXPLORER_LOC_FAILED; + ok = netloc_reconcile_run(0, changed); + sd_config_unlock(lock); + return ok ? EXPLORER_LOC_OK : EXPLORER_LOC_FAILED; +} diff --git a/apps/shelldrive-host/explorer_data.h b/apps/shelldrive-host/explorer_data.h new file mode 100644 index 0000000..c0c7f88 --- /dev/null +++ b/apps/shelldrive-host/explorer_data.h @@ -0,0 +1,69 @@ +/* Explorer data written by the agent (03 §8.3, §8.7; M3): the per-user drive icon of each ShellDrive + * drive and the Network location entries of the profiles that ask for one. Labels are not written here: + * the WebDAV label is written by sd_webdav_map (_LabelFromReg), Dokany takes its volume label. + * + * Every function runs on the control thread WITHOUT the agent lock (the agent posts a work item with a + * copy of the mount's data). Registry and file writes never happen under g.lock. */ +#ifndef SD_HOST_EXPLORER_DATA_H +#define SD_HOST_EXPLORER_DATA_H + +#include + +/* One mount as Explorer needs it, copied out of the agent's record. */ +typedef struct host_explorer_mount { + wchar_t letter; /* 'A'..'Z'; 0 = nothing to do */ + int present; /* the drive is mapped: mounted, offline or auth_required */ + int network_location; /* the profile has mount.network_location = yes */ + char profile_id[33]; /* "" for ad hoc mounts (no Network location) */ + char profile_name[128]; /* UTF-8 display name */ +} host_explorer_mount; + +/* Reads the test hooks (SD_HOST_TEST_HOOKS builds only) and decides which parts are on. Shipping builds: + icons and Network locations on (policy DisableNetworkLocations is read at start). Test builds: each part + is on only when its test variable is set, so a test can never write the user's real keys or folders: + SHELLDRIVE_TEST_EXPLORER_KEY HKCU subkey used instead of Software\Classes\Applications\Explorer.exe\Drives + SHELLDRIVE_TEST_NETHOOD folder used instead of the NetHood folder */ +void explorer_init(void); + +/* Start: removes icons listed in state\explorer.ini (crash leftovers) and reconciles the Network location + folders with the profiles (03 §8.7, lifecycle). Control thread, before the first mount. */ +void explorer_reconcile_start(void); + +/* A mount changed: sets or removes the icon of its letter (SHChangeNotify on the change), and points its + Network location at the mount root (present) or at the placeholder folder (not present). */ +void explorer_apply(const host_explorer_mount *m); + +/* Shutdown, after every mount is gone: removes the icons this agent wrote (explorer.ini). */ +void explorer_shutdown(void); + +/* The profiles changed (config watch): reconciles the Network location folders with them (create, rename, + remove); entries of mounted profiles keep pointing at their drive. Control thread. */ +void explorer_config_changed(void); + +/* User uninstall (installer hook user-cleanup): removes every Network location entry of ours, the placeholder + folders and the icons listed in explorer.ini. Entries without our marker are never touched. No agent needed. */ +void explorer_cleanup_user(void); + +/* shelldrive location add|remove|repair (03 §6.1, §8.7), run by shelldrive.exe without an agent. Each returns one of + the EXPLORER_LOC_* codes. All three refuse with EXPLORER_LOC_POLICY when policy DisableNetworkLocations is set, and + change nothing then. Only entries with our [ShellDrive] profile= marker are ever deleted. The entries of mounted + profiles are not re-pointed here (no mount state is known without the agent): the agent corrects them at its next + profile change or start. */ +enum { + EXPLORER_LOC_OK = 0, + EXPLORER_LOC_POLICY, /* DisableNetworkLocations = 1 */ + EXPLORER_LOC_FAILED /* the NetHood folder or a placeholder could not be written */ +}; + +/* 1 when policy DisableNetworkLocations = 1 (the CLI checks it before it writes a profile). */ +int explorer_location_blocked(void); + +/* add: the entry of one profile (id, UTF-8 display name) exists after the call; *created = 1 when it was made. */ +int explorer_location_add(const char *id, const char *name, int *created); +/* remove: the entry and the placeholder of one profile are gone; *removed = 1 when an entry was deleted. */ +int explorer_location_remove(const char *id, int *removed); +/* repair: entries of profiles that are gone or no longer ask for one are deleted, missing ones are created, under + config.lock. *changed = entries created plus entries deleted. */ +int explorer_location_repair(int *changed); + +#endif /* SD_HOST_EXPLORER_DATA_H */ diff --git a/apps/shelldrive-host/installer_hooks.c b/apps/shelldrive-host/installer_hooks.c new file mode 100644 index 0000000..f79bb5e --- /dev/null +++ b/apps/shelldrive-host/installer_hooks.c @@ -0,0 +1,152 @@ +/* shelldrive-host --installer-hook --apply|--revert (docs/design/03 §6.1, 04 §8.3). + * + * Hidden verbs run by the installers (the setup engine per user, MSI deferred actions per machine). Each is + * idempotent. signal-shutdown is implemented here for the current account: it sets the shutdown events of + * the agents of this user and session (04 §6.9 step 1). The other hooks are part of M5 (the service and + * the Explorer/WebClient integration); they log that they are not implemented and succeed, so that an + * installer run is not blocked by a hook that has nothing to do yet. */ +#include +#include +#include +#include +#include +#include +#include + +#include "svc_repair.h" +#include "explorer_data.h" + +#define SD_HOOK_AGENT_EVENT L"Global\\com.librestatic.shelldrive.agent.shutdown." + +typedef BOOL (WINAPI *sd_ProcessIdToSessionId_t)(DWORD, DWORD *); + +static const wchar_t *const k_hooks[] = { + L"webdav-tune", L"user-cleanup", L"modern-menu", L"signal-shutdown", L"programdata-secure" +}; + +/* %ProgramData%\LibreStatic and \ShellDrive are checked by handle and set back to the 3.2 DACLs; an untrusted folder is + moved aside (TM-R34, 04 §6.4). The same repair the setup stub runs before any elevated write. */ +static int programdata_secure(void) +{ + wchar_t base[MAX_PATH]; + svc_repair_ops ops; + svc_repair_result rr; + DWORD err; + + if (FAILED(SHGetFolderPathW(NULL, CSIDL_COMMON_APPDATA, NULL, SHGFP_TYPE_CURRENT, base))) { + wprintf(L"programdata-secure: the common application data folder is not known\n"); + return 1; + } + svc_repair_real_ops(&ops); + memset(&rr, 0, sizeof(rr)); + err = svc_repair_data(&ops, base, &rr); + if (err != 0) { + wprintf(L"programdata-secure: the ProgramData folders could not be secured (error %lu)\n", (unsigned long)err); + return 1; + } + if (rr.moved) + wprintf(L"programdata-secure: an untrusted folder was moved aside to %s\n", rr.path); + return 0; +} + +/* Shutdown events of this account's agent in this session (the current process session). */ +static int signal_own_agents(void) +{ + HANDLE token = NULL; + BYTE buf[256]; + DWORD need = 0; + TOKEN_USER *tu = (TOKEN_USER *)buf; + wchar_t *sid_text = NULL; + wchar_t name[256]; + DWORD session = 0; + HANDLE ev; + sd_ProcessIdToSessionId_t sess_of; + HMODULE k32 = GetModuleHandleW(L"kernel32.dll"); + int rc = 1; + + if (!OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &token)) + return 1; + if (!GetTokenInformation(token, TokenUser, buf, sizeof(buf), &need) || + !ConvertSidToStringSidW(tu->User.Sid, &sid_text)) { + CloseHandle(token); + return 1; + } + CloseHandle(token); + + sess_of = k32 != NULL ? (sd_ProcessIdToSessionId_t)GetProcAddress(k32, "ProcessIdToSessionId") : NULL; + if (sess_of == NULL || !sess_of(GetCurrentProcessId(), &session)) { + LocalFree(sid_text); + return 1; + } + + StringCchPrintfW(name, sizeof(name) / sizeof(name[0]), L"%s%s.%lu", SD_HOOK_AGENT_EVENT, sid_text, + (unsigned long)session); + LocalFree(sid_text); + + ev = OpenEventW(EVENT_MODIFY_STATE, FALSE, name); + if (ev == NULL) { + wprintf(L"signal-shutdown: no agent of this account is running in this session\n"); + return 0; + } + if (SetEvent(ev)) { + wprintf(L"signal-shutdown: the agent was asked to exit\n"); + rc = 0; + } + CloseHandle(ev); + return rc; +} + +/* argv as given to wmain: argv[1] == "--installer-hook", argv[2] = name, then --apply or --revert. */ +int sd_host_installer_hook(int argc, wchar_t **argv) +{ + const wchar_t *name; + int known = 0; + int apply = 0; + int i; + size_t k; + + if (argc < 4) { + wprintf(L"usage: shelldrive-host --installer-hook --apply|--revert\n"); + return 2; + } + name = argv[2]; + for (k = 0; k < sizeof(k_hooks) / sizeof(k_hooks[0]); k++) { + if (wcscmp(name, k_hooks[k]) == 0) + known = 1; + } + if (!known) { + wprintf(L"shelldrive-host: unknown installer hook %s\n", name); + return 2; + } + for (i = 3; i < argc; i++) { + if (wcscmp(argv[i], L"--apply") == 0) + apply = 1; + else if (wcscmp(argv[i], L"--revert") != 0) { + wprintf(L"shelldrive-host: unknown option %s\n", argv[i]); + return 2; + } + } + + if (wcscmp(name, L"signal-shutdown") == 0) { + if (!apply) + return 0; /* nothing to undo: the agents were asked to exit already */ + return signal_own_agents(); + } + + if (wcscmp(name, L"programdata-secure") == 0) { + /* Nothing to undo: the folders are trusted and stay. */ + if (!apply) + return 0; + return programdata_secure(); + } + + if (wcscmp(name, L"user-cleanup") == 0) { + /* Explorer data of this user (03 8.7); other users of a per-machine install keep inert entries. */ + if (apply) + explorer_cleanup_user(); + return 0; + } + + wprintf(L"installer hook %s: not yet implemented (M5)\n", name); + return 0; +} diff --git a/apps/shelldrive-host/log_ring.c b/apps/shelldrive-host/log_ring.c new file mode 100644 index 0000000..05dd070 --- /dev/null +++ b/apps/shelldrive-host/log_ring.c @@ -0,0 +1,128 @@ +/* In-memory log ring (see log_ring.h). The sink parses the formatted line of sd_log: + * [] + * Parsing never logs; the sink runs with the logger lock held. */ +#include +#include + +#include "log_ring.h" +#include "sd_base/sd_mem.h" +#include "sd_platform/sd_log.h" +#include "sd_platform/sd_thread.h" + +static struct { + sd_mutex lock; + log_ring_rec *recs; /* LOG_RING_RECORDS slots, slot = (seq - 1) % LOG_RING_RECORDS */ + uint64_t next_seq; /* seq of the next record (starts at 1) */ + int started; +} g_ring; + +/* Copies the next whitespace-delimited token of [*p, end) into out (truncated). */ +static void next_token(const char **p, const char *end, char *out, size_t cap) +{ + const char *s = *p; + size_t n = 0; + while (s < end && (*s == ' ' || *s == '\t')) s++; + while (s < end && *s != ' ' && *s != '\t') { + if (n + 1 < cap) out[n++] = *s; + s++; + } + out[n] = 0; + *p = s; +} + +static int level_of(const char *tok) +{ + if (strcmp(tok, "ERROR") == 0) return SD_LOG_ERROR; + if (strcmp(tok, "WARN") == 0) return SD_LOG_WARN; + if (strcmp(tok, "DEBUG") == 0) return SD_LOG_DEBUG; + if (strcmp(tok, "TRACE") == 0) return SD_LOG_TRACE; + return SD_LOG_INFO; +} + +static void parse_line(const char *line, size_t len, log_ring_rec *r) +{ + const char *p = line, *end = line + len; + char tok[64]; + memset(r, 0, sizeof(*r)); + next_token(&p, end, r->time, sizeof(r->time)); + next_token(&p, end, tok, sizeof(tok)); /* pid */ + next_token(&p, end, tok, sizeof(tok)); /* tid */ + next_token(&p, end, tok, sizeof(tok)); + r->level = level_of(tok); + next_token(&p, end, r->component, sizeof(r->component)); + while (p < end && *p == ' ') p++; + if (p < end && *p == '[') { + const char *q = p + 1; + size_t n = 0; + while (q < end && *q != ']' && n + 1 < sizeof(r->mount_id)) r->mount_id[n++] = *q++; + r->mount_id[n] = 0; + if (q < end && *q == ']') p = q + 1; + } + while (p < end && *p == ' ') p++; + { + size_t n = (size_t)(end - p); + if (n > sizeof(r->text) - 1) n = sizeof(r->text) - 1; + memcpy(r->text, p, n); + r->text[n] = 0; + } +} + +static void ring_sink(void *ctx, sd_log_level level, const char *line, size_t len) +{ + log_ring_rec tmp; + SD_UNUSED(ctx); + SD_UNUSED(level); + parse_line(line, len, &tmp); + sd_mutex_lock(&g_ring.lock); + tmp.seq = g_ring.next_seq++; + g_ring.recs[(tmp.seq - 1) % LOG_RING_RECORDS] = tmp; + sd_mutex_unlock(&g_ring.lock); +} + +sd_err log_ring_start(void) +{ + if (g_ring.started) + return SD_OK; + g_ring.recs = (log_ring_rec *)sd_calloc(LOG_RING_RECORDS, sizeof(log_ring_rec)); + if (g_ring.recs == NULL) + return SD_E_NO_MEMORY; + sd_mutex_init(&g_ring.lock); + g_ring.next_seq = 1; + if (sd_log_add_sink(ring_sink, NULL) != SD_OK) { + sd_free(g_ring.recs); + g_ring.recs = NULL; + return SD_E_INTERNAL; + } + g_ring.started = 1; + return SD_OK; +} + +void log_ring_stop(void) +{ + if (!g_ring.started) + return; + sd_log_remove_sink(ring_sink, NULL); + g_ring.started = 0; + sd_free(g_ring.recs); + g_ring.recs = NULL; +} + +void log_ring_scan(uint64_t from, uint64_t *oldest, uint64_t *newest, log_ring_fn fn, void *ctx) +{ + uint64_t first, last, s; + if (oldest) *oldest = 0; + if (newest) *newest = 0; + if (!g_ring.started) + return; + sd_mutex_lock(&g_ring.lock); + last = g_ring.next_seq - 1; + first = last >= LOG_RING_RECORDS ? last - LOG_RING_RECORDS + 1 : (last ? 1 : 0); + if (oldest) *oldest = first; + if (newest) *newest = last; + if (last && from < first) from = first; + for (s = from; last && s <= last; s++) { + if (!fn(ctx, &g_ring.recs[(s - 1) % LOG_RING_RECORDS])) + break; + } + sd_mutex_unlock(&g_ring.lock); +} diff --git a/apps/shelldrive-host/log_ring.h b/apps/shelldrive-host/log_ring.h new file mode 100644 index 0000000..db00b39 --- /dev/null +++ b/apps/shelldrive-host/log_ring.h @@ -0,0 +1,37 @@ +/* In-memory ring of the agent's most recent log records (03 §10.1, M3 `log.tail`, decision 12). + * + * Fed by an sd_log sink, so every record is already formatted and redacted by sd_platform logging. + * The ring keeps LOG_RING_RECORDS records (about 4.5 MiB worst case); sequence numbers start at 1 and + * are never reused within one agent process. Records are copied, never referenced, so readers never + * see a torn record. */ +#ifndef SD_HOST_LOG_RING_H +#define SD_HOST_LOG_RING_H + +#include + +#include "sd/base.h" +#include "sd_base/sd_err.h" +#include "sd_platform/sd_log.h" + +#define LOG_RING_RECORDS 4096 +#define LOG_RING_TEXT 1024 + +typedef struct log_ring_rec { + uint64_t seq; + int level; /* sd_log_level: 0 error .. 4 trace */ + char time[32]; /* ISO 8601 UTC, as printed in the log line */ + char component[32]; + char mount_id[16]; /* "" when the record is not about a mount */ + char text[LOG_RING_TEXT]; /* English message, truncated */ +} log_ring_rec; + +/* Allocates the ring and installs the sink. Call once, before the first log record that must be kept. */ +sd_err log_ring_start(void); +void log_ring_stop(void); + +/* Visits records with seq >= from in ascending order, under the ring lock; fn returns 0 to stop. + *oldest and *newest report the retained range (0 and 0 when the ring is empty). fn must not log. */ +typedef int (*log_ring_fn)(void *ctx, const log_ring_rec *r); +void log_ring_scan(uint64_t from, uint64_t *oldest, uint64_t *newest, log_ring_fn fn, void *ctx); + +#endif /* SD_HOST_LOG_RING_H */ diff --git a/apps/shelldrive-host/main.c b/apps/shelldrive-host/main.c index 0ac3da8..8a466e2 100644 --- a/apps/shelldrive-host/main.c +++ b/apps/shelldrive-host/main.c @@ -2,14 +2,20 @@ * * shelldrive-host agent [--logon] per-user agent (single instance per user + session) * shelldrive-host --selftest health check, no network (exit 0 = healthy) + * shelldrive-host service ShellDriveSvc under the Service Control Manager (M5, 03 §1.3) + * shelldrive-host service-debug the same service core in a console, without the SCM (M5) * shelldrive-host --version - * The service mode (`service`) arrives in M5. The host does not localize (03 §9.3): it produces - * codes, clients localize. */ + * The host does not localize the agent and its codes (03 §9.3): clients localize. The service prints its own + * console and SCM messages in the svc domain. */ #include #include #include "host.h" #include "sd/version.h" +#include "service.h" + +/* installer_hooks.c: hidden --installer-hook verbs (03 §6.1, 04 §8.3). */ +int sd_host_installer_hook(int argc, wchar_t **argv); #include "sd_config/sd_config.h" #include "sd_ipc/sd_ipc.h" #include "sd_platform/sd_log.h" @@ -88,6 +94,8 @@ int wmain(int argc, wchar_t **argv) } if (argc >= 2 && wcscmp(argv[1], L"--selftest") == 0) return selftest(); + if (argc >= 2 && wcscmp(argv[1], L"--installer-hook") == 0) + return sd_host_installer_hook(argc, argv); if (argc >= 2 && wcscmp(argv[1], L"agent") == 0) { memset(&opts, 0, sizeof(opts)); opts.idle_exit_ms = 5u * 60u * 1000u; @@ -104,6 +112,11 @@ int wmain(int argc, wchar_t **argv) sd_log_close(); return rc; } - out(L"usage: shelldrive-host agent [--logon] | --selftest | --version\r\n"); + /* M5: the service core logs to its own file (\logs\service.log) and localizes its console text */ + if (argc >= 2 && wcscmp(argv[1], L"service") == 0) + return svc_dispatch(); + if (argc >= 2 && wcscmp(argv[1], L"service-debug") == 0) + return svc_console_run(); + out(L"usage: shelldrive-host agent [--logon] | service | service-debug | --selftest | --version\r\n"); return 2; } diff --git a/apps/shelldrive-host/service.c b/apps/shelldrive-host/service.c new file mode 100644 index 0000000..f905dd5 --- /dev/null +++ b/apps/shelldrive-host/service.c @@ -0,0 +1,3425 @@ +/* shelldrive-host service mode (03 §1.3, §2.2, §2.6, §3.2, §3.4.1, §4.5; D6, D17, D35; TM-R24..R27). + * + * Owns global mounts only: machine profiles with scope = service. The control thread is the caller of svc_run (the SCM + * main thread or the console thread). sd_mount create/start/stop/destroy run only there; the pipe thread (IPC handlers) + * touches the tables under g.lock and hands work to the control thread through a queue, like the agent does. + * + * Methods (2.4): list and status (filtered, 2.2), selftest and hello are open to every client; mount, unmount, + * secret.set, secret.delete, config.reload, prompt.list and prompt.answer need an elevated administrator. Topics prompt, + * log and hostkeys are refused to non-admins (topic gate). A service prompt is shown only to an elevated administrator + * that subscribed to `prompt`; without one the mount fails with prompt_unavailable (2.6 rule 3). + * + * Host keys of the machine store (M5, 03 §2.6 rule 2, §5.6, §6.1): hostkey.add connects a hostkey mount with a machine + * profile's own service credentials (no prompt is answered: the connection is refused every prompt except the + * host-key question, which sd_mount takes itself), keeps the offered keys as announcements, and + * hostkey.announced.accept writes them to the machine known_hosts after svc_store_check. Mount announcements (UpdateHostKeys + * ask) are kept the same way and raise Event 2300. */ +#include +#include +#include +#include +#include +#include + +#include "host.h" +#include "service.h" +#include "svc_access.h" +#include "svc_events.h" +#include "svc_repair.h" +#include "svc_scm.h" +#include "svc_status.h" +#include "sd/version.h" +#include "sd_base/sd_json.h" +#include "sd_base/sd_mem.h" +#include "sd_config/sd_config.h" +#include "sd_config/sd_ini.h" +#include "sd_ipc/sd_ipc.h" +#include "sd_l10n/sd_l10n.h" +#include "sd_l10n_svc_ids.h" +#include "sd_platform/sd_log.h" +#include "sd_platform/sd_platform.h" +#include "sd_platform/sd_thread.h" +#include "sd_platform/sd_time.h" +#include "sd_policy/sd_policy.h" +#include "sd_secret/sd_secret.h" +#include "sd_ssh/sd_ssh.h" + +/* Service control values that older SDK headers may not define (the XP flavour builds against 7.1A). */ +#ifndef SERVICE_CONTROL_PRESHUTDOWN +#define SERVICE_CONTROL_PRESHUTDOWN 0x0000000F +#endif +#ifndef SERVICE_ACCEPT_PRESHUTDOWN +#define SERVICE_ACCEPT_PRESHUTDOWN 0x00000100 +#endif +#ifndef SERVICE_ACCEPT_POWEREVENT +#define SERVICE_ACCEPT_POWEREVENT 0x00000040 +#endif +#ifndef SERVICE_ACCEPT_SESSIONCHANGE +#define SERVICE_ACCEPT_SESSIONCHANGE 0x00000080 +#endif + +#define SVC_MAX_PROFILES 64 +#define SVC_MAX_MOUNTS 32 +#define SVC_MAX_PROMPTS 16 +#define SVC_PROMPT_MS 120000u /* 2.5: user-initiated operations; the service has no reconnect prompts */ +#define SVC_MAX_MESSAGE_NONADMIN 65536u /* 2.2, TM-R25: request cap of clients that are not elevated admins (admins: 1 MiB) */ +#define SVC_RESERVED_ADMIN_INSTANCES 2u /* 2.2, TM-R25: of the 32 pipe instances, these are kept for elevated admins */ +#define SVC_IDLE_MS 60000u /* 2.2: idle timeout of a non-subscribed client */ +#define SVC_HELLO_MS 5000u /* 2.2 (not given in 03 §2.2): a non-admin must send hello within 5 s of connecting */ +#define SVC_SHUTDOWN_MS 15000u +#define SVC_MAX_HK 2u /* hostkey.add connections at once (each holds one sd_ssh connection) */ +#define SVC_MAX_ANNOUNCE 32u /* kept announcements (hostkey.announced.list); the oldest goes when full */ + +/* ---- records ---- */ + +typedef struct svc_prof { + sd_profile p; + svc_acl acl; +} svc_prof; + +typedef struct svc_prompt { + int used; + uint32_t id; /* "p-" on the wire */ + struct svc_mount *mount; + uint32_t mprompt; /* sd_mount prompt id */ + uint64_t deadline; + int kind; + char mount_id[16]; + char profile_id[33]; + char host[256]; + uint16_t port; + char user[128]; + int is_reconnect, can_save; +} svc_prompt; + +typedef struct svc_mount { + char id[16]; /* "g-" */ + wchar_t letter; + svc_prof *prof; /* the profile as it was when mounted */ + sd_mount *m; /* control thread only (NULL before create and after destroy) */ + sd_mount_state st; + int stop_req, final, prompt_refused, started; + const char *refuse_code; /* IPC code of the refused prompt (prompt_unavailable, host_key_unknown) */ + char err_code[64]; + char err_detail[160]; +} svc_mount; + +typedef struct svc_job { + char id[16]; + int force; +} svc_job; + +/* A host key kept for review: an UpdateHostKeys announcement of a global mount (mount_id set) or the keys of a + hostkey.add offer (mount_id ""). hostkey.announced.accept writes it to the machine store (5.6). */ +typedef struct svc_ann { + struct svc_ann *next; + char id[16]; /* "a-" on the wire */ + char mount_id[16]; + sd_mount_announcement a; +} svc_ann; + +/* One hostkey.add request: a connect-only hostkey mount on the control thread. The reply is deferred (sd_ipc_defer) and + sent once, when the mount is final (svc_hk_reply); the record is released by svc_tick after that. */ +typedef struct svc_hk { + struct svc_hk *next; + sd_mount *m; /* control thread only; NULL when the mount was never created */ + svc_prof *prof; /* a copy of the machine profile (control thread only) */ + uint64_t defer_cid; + int64_t defer_rid; + int replied; /* control thread only: the reply is sent, the record can be released */ + char host[256]; + uint16_t port; + char lookup[300]; + char type[64]; + char expect[96]; +} svc_hk; + +typedef struct svc_work { + struct svc_work *next; + void (*fn)(void *arg); + void *arg; +} svc_work; + +static struct svc_state { + sd_mutex lock; /* tables, queue, prompt slots; never held across a sd_mount call */ + sd_mutex reap; /* serializes sd_mount_destroy with sd_mount_answer (both may be on other threads) */ + sd_event wake, stop; + sd_ipc_server *srv; + const sd_mount_backend *be; + wchar_t root[MAX_PATH], profiles[MAX_PATH], service_dir[MAX_PATH], kh[MAX_PATH], logfile[MAX_PATH]; + wchar_t vendor[MAX_PATH]; /* %ProgramData%\LibreStatic, default location only (D45, 3.2) */ + svc_owner_fn owner_trusted; /* NULL = the shipping owner rule (3.2); tests replace it */ + const wchar_t *dacl_extra; /* SD_SVC_TEST_HOOKS only: an ACE appended to every machine DACL (svc_opts) */ + int idle; /* layout refused (owner, reparse point, no rights): nothing is loaded */ + svc_folder_reason refused_why; /* why the layout was refused (idle), SVC_FOLDER_OK otherwise */ + wchar_t refused_path[MAX_PATH]; /* the first refused folder (idle), empty otherwise */ + int open_log; /* svc_opts.open_log: the repair closes and reopens the log around the move */ + int repairing; /* a service.repair_data runs: a second one is busy */ + const svc_scm_ops *scm_ops; /* svc_opts seams of service.status and service.repair_data */ + const svc_repair_ops *repair_ops; + const svc_sysproxy_ops *sysproxy; /* proxy = system seams (svc_opts.sysproxy_ops); the shipping ops refuse by policy */ + const wchar_t *repair_base; + int running, stopping; + int test_all_admin; /* SD_SVC_TEST_HOOKS only, see caller_is_admin */ +#ifdef SD_SVC_TEST_HOOKS + int test_hk_policy_forced; /* SD_SVC_TEST_HOOKS only: svc_test_hk_policy replaces the registry value */ + DWORD test_hk_policy; + int test_connect_admin; /* SD_SVC_TEST_HOOKS only: every new connection counts as admin at connect */ + int test_admin_any; /* SD_SVC_TEST_HOOKS only: every client, whatever its hello kind, is an admin for the methods */ + int test_sysproxy_forced; /* SD_SVC_TEST_HOOKS only: svc_test_sysproxy_policy replaces the machine policies */ + DWORD test_sysproxy_allow, test_sysproxy_sspi; +#endif + svc_event_fn event; /* Event Log sink (10.3), NULL = none */ + void *event_ctx; + svc_prof *profs[SVC_MAX_PROFILES]; + uint32_t nprofs; + svc_mount *mounts[SVC_MAX_MOUNTS]; + uint32_t next_mount, next_prompt; + svc_prompt prompts[SVC_MAX_PROMPTS]; + svc_ann *anns; /* kept host keys (g.lock); nann counts them */ + uint32_t nann, next_ann; + svc_hk *hks; /* hostkey.add requests in flight (g.lock); nhk counts them */ + uint32_t nhk; + svc_work *qh, *qt; + svc_refuse_entry refuse[SVC_REFUSE_SLOTS]; /* Event 3101 throttle; written on the pipe thread only */ +} g; + +/* Admin check of the callers. SD_SVC_TEST_HOOKS builds (tests/unit/sd_svc) may treat a "testadmin" client as an elevated + administrator so that the admin methods are exercised without an elevated test process; the restricted-token client + still hits the real check. Shipping builds never compile the override. */ +static int caller_is_admin(sd_ipc_client *c) +{ +#ifdef SD_SVC_TEST_HOOKS + if (g.test_all_admin && strcmp(sd_ipc_client_kind(c), "testadmin") == 0) + return 1; + if (g.test_admin_any) + return 1; +#endif + return sd_ipc_client_is_admin(c) ? 1 : 0; +} + +/* ---- small helpers ---- */ + +static void svc_log_info(const char *fmt, ...); + +static void wide_from_utf8(const char *s, wchar_t *out, size_t cch) +{ + if (cch == 0) return; + out[0] = 0; + if (s && s[0]) + MultiByteToWideChar(CP_UTF8, 0, s, -1, out, (int)cch); + out[cch - 1] = 0; +} + +static void utf8_from_wide(const wchar_t *w, char *out, size_t cch) +{ + if (cch == 0) return; + out[0] = 0; + if (w && w[0]) + WideCharToMultiByte(CP_UTF8, 0, w, -1, out, (int)cch, NULL, NULL); + out[cch - 1] = 0; +} + +static uint64_t svc_now(void) { return sd_tick_ms(); } + +static void target_of(wchar_t letter, char out[4]) +{ + out[0] = (char)letter; + out[1] = ':'; + out[2] = 0; +} + +static void say(sd_msg_id id) +{ + wchar_t *s = sd_l10n_format_alloc(id, NULL, 0); + HANDLE h = GetStdHandle(STD_OUTPUT_HANDLE); + if (s == NULL) + return; + if (h != NULL && h != INVALID_HANDLE_VALUE) { + DWORD n = 0; + if (GetConsoleMode(h, &n)) { + WriteConsoleW(h, s, (DWORD)wcslen(s), &n, NULL); + WriteConsoleW(h, L"\r\n", 2, &n, NULL); + } else { + char buf[1024]; + int len = WideCharToMultiByte(CP_UTF8, 0, s, -1, buf, (int)sizeof(buf), NULL, NULL); + if (len > 1) + WriteFile(h, buf, (DWORD)(len - 1), &n, NULL); + WriteFile(h, "\r\n", 2, &n, NULL); + } + } + sd_free(s); +} + +static void svc_log_info(const char *fmt, ...) +{ + char line[512]; + va_list ap; + va_start(ap, fmt); + _vsnprintf_s(line, sizeof(line), _TRUNCATE, fmt, ap); + va_end(ap); + SD_LOGI("svc", "%s", line); +} + +/* ---- work queue (the control thread runs it) ---- */ + +static void svc_enqueue(void (*fn)(void *), void *arg) +{ + svc_work *w = (svc_work *)sd_alloc(sizeof(*w)); + if (w == NULL) + return; + w->next = NULL; + w->fn = fn; + w->arg = arg; + sd_mutex_lock(&g.lock); + if (g.qt) g.qt->next = w; else g.qh = w; + g.qt = w; + sd_mutex_unlock(&g.lock); + SetEvent(g.wake.h); +} + +static void svc_post(void *ctx, void (*fn)(void *), void *arg) +{ + (void)ctx; + svc_enqueue(fn, arg); +} + +static void svc_drain(void) +{ + for (;;) { + svc_work *w; + sd_mutex_lock(&g.lock); + w = g.qh; + if (w) { + g.qh = w->next; + if (g.qh == NULL) g.qt = NULL; + } + sd_mutex_unlock(&g.lock); + if (w == NULL) + break; + w->fn(w->arg); + sd_free(w); + } +} + +/* ---- machine layout (3.2) ---- */ + +static sd_err svc_compute_paths(const wchar_t *override_root) +{ + if (override_root && override_root[0]) { + wcsncpy_s(g.root, MAX_PATH, override_root, _TRUNCATE); + } else { + wchar_t base[MAX_PATH]; + if (SHGetFolderPathW(NULL, CSIDL_COMMON_APPDATA, NULL, 0, base) != S_OK) + return SD_E_FAILURE; + _snwprintf_s(g.root, MAX_PATH, _TRUNCATE, L"%s\\LibreStatic\\ShellDrive", base); + _snwprintf_s(g.vendor, MAX_PATH, _TRUNCATE, L"%s\\LibreStatic", base); + } + _snwprintf_s(g.profiles, MAX_PATH, _TRUNCATE, L"%s\\profiles", g.root); + _snwprintf_s(g.service_dir, MAX_PATH, _TRUNCATE, L"%s\\service", g.root); + _snwprintf_s(g.kh, MAX_PATH, _TRUNCATE, L"%s\\known_hosts", g.root); + _snwprintf_s(g.logfile, MAX_PATH, _TRUNCATE, L"%s\\logs\\service.log", g.root); + return SD_OK; +} + +/* The 3.2 DACLs of the machine folders (SDDL with inheritance), set when a folder is created and re-applied at start + when it is wrong (svc_check_existing). */ +static const wchar_t k_svc_dacl_admin_read[] = L"D:PAI(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(A;OICI;FR;;;BU)"; +static const wchar_t k_svc_dacl_admin[] = L"D:PAI(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)"; +static const wchar_t k_svc_dacl_system[] = L"D:PAI(A;OICI;FA;;;SY)"; + +/* The first machine folder refused at start (3.2, TM-R34) and why: Event 3102 names both. */ +typedef struct svc_refusal { + wchar_t path[MAX_PATH]; + svc_folder_reason why; +} svc_refusal; + +static void svc_refuse(svc_refusal *w, const wchar_t *path, svc_folder_reason why) +{ + if (w == NULL) + return; + _snwprintf_s(w->path, MAX_PATH, _TRUNCATE, L"%s", path); + w->why = why; +} + +/* The DACL text of a machine folder: the 3.2 SDDL, plus the test seam's extra ACE (SD_SVC_TEST_HOOKS builds only; the + shipping build has no extra ACE). */ +static void svc_dacl_text(const wchar_t *sddl, wchar_t *out, size_t cch) +{ + _snwprintf_s(out, cch, _TRUNCATE, L"%s%s", sddl, g.dacl_extra != NULL ? g.dacl_extra : L""); +} + +/* An existing machine folder is used only when it is a folder, not a reparse point, and its owner is trusted (3.2). + A refused folder is never fixed here: repair-data does that. Every check runs on a handle that does not follow links. */ +static sd_err svc_check_existing(const wchar_t *path, const wchar_t *sddl, svc_refusal *w) +{ + HANDLE h = NULL; + svc_folder_reason why = SVC_FOLDER_NO_RIGHTS; + wchar_t dtext[512]; + DWORD e = svc_folder_open(path, READ_CONTROL | WRITE_DAC | FILE_READ_ATTRIBUTES, &h); + if (e != 0) /* WRITE_DAC is only needed when the DACL is wrong: the checks themselves need READ_CONTROL */ + e = svc_folder_open(path, READ_CONTROL | FILE_READ_ATTRIBUTES, &h); + if (e == 0) { + int changed = 0; + e = svc_folder_inspect(h, 1, g.owner_trusted, NULL, &why); + if (e != 0) { + why = SVC_FOLDER_NO_RIGHTS; + } else if (why == SVC_FOLDER_OK) { + /* 3.2: the start re-applies the machine DACL of a trusted folder when it is wrong (repair-data is still the way + to fix an untrusted one) */ + svc_dacl_text(sddl, dtext, SD_COUNTOF(dtext)); + if (svc_folder_apply_dacl(h, dtext, &changed) != 0) + why = SVC_FOLDER_ACL; + else if (changed) + SD_LOGI("svc", "the 3.2 DACL of a machine folder was re-applied at start"); + } + CloseHandle(h); + } + if (why != SVC_FOLDER_OK) { + svc_refuse(w, path, why); + return SD_E_ACCESS_DENIED; + } + return SD_OK; +} + +/* Creates one folder with its 3.2 DACL when it is missing. An existing folder is checked (svc_check_existing); a folder + that cannot be created refuses the layout. */ +static sd_err svc_mkdir(const wchar_t *path, const wchar_t *sddl, svc_refusal *w) +{ + HANDLE h = NULL; + DWORD e = svc_folder_open(path, FILE_READ_ATTRIBUTES, &h); + PSECURITY_DESCRIPTOR sd = NULL; + SECURITY_ATTRIBUTES sa; + wchar_t dtext[512]; + if (e == 0) { + CloseHandle(h); + return svc_check_existing(path, sddl, w); + } + if (e != ERROR_FILE_NOT_FOUND && e != ERROR_PATH_NOT_FOUND) { + svc_refuse(w, path, SVC_FOLDER_NO_RIGHTS); + return SD_E_ACCESS_DENIED; + } + svc_dacl_text(sddl, dtext, SD_COUNTOF(dtext)); + if (!ConvertStringSecurityDescriptorToSecurityDescriptorW(dtext, SDDL_REVISION_1, &sd, NULL)) { + svc_refuse(w, path, SVC_FOLDER_NO_RIGHTS); + return SD_E_INTERNAL; + } + memset(&sa, 0, sizeof(sa)); + sa.nLength = sizeof(sa); + sa.lpSecurityDescriptor = sd; + if (!CreateDirectoryW(path, &sa)) { + e = GetLastError(); + LocalFree(sd); + if (e == ERROR_ALREADY_EXISTS) + return svc_check_existing(path, sddl, w); + svc_refuse(w, path, SVC_FOLDER_NO_RIGHTS); + return SD_E_ACCESS_DENIED; + } + LocalFree(sd); + return SD_OK; +} + +/* The vendor folder (D45), only for the default location (the tests create their own root). It is checked like the + rest of the tree; when it is missing nothing is created here (the data folder is created below it by svc_mkdir). */ +static sd_err svc_check_vendor(svc_refusal *w) +{ + HANDLE h = NULL; + DWORD e; + if (g.vendor[0] == 0) + return SD_OK; + e = svc_folder_open(g.vendor, FILE_READ_ATTRIBUTES, &h); + if (e == ERROR_FILE_NOT_FOUND || e == ERROR_PATH_NOT_FOUND) + return SD_OK; + if (e == 0) + CloseHandle(h); + return svc_check_existing(g.vendor, k_svc_dacl_admin_read, w); +} + +static sd_err svc_ensure_layout(svc_refusal *w) +{ + wchar_t path[MAX_PATH]; + sd_err e; + if ((e = svc_check_vendor(w)) != SD_OK) return e; + if ((e = svc_mkdir(g.root, k_svc_dacl_admin_read, w)) != SD_OK) return e; + _snwprintf_s(path, MAX_PATH, _TRUNCATE, L"%s\\logs", g.root); + if ((e = svc_mkdir(path, k_svc_dacl_admin, w)) != SD_OK) return e; + if ((e = svc_mkdir(g.profiles, k_svc_dacl_admin_read, w)) != SD_OK) return e; + if ((e = svc_mkdir(g.service_dir, k_svc_dacl_admin, w)) != SD_OK) return e; + _snwprintf_s(path, MAX_PATH, _TRUNCATE, L"%s\\secrets", g.service_dir); + if ((e = svc_mkdir(path, k_svc_dacl_system, w)) != SD_OK) return e; + _snwprintf_s(path, MAX_PATH, _TRUNCATE, L"%s\\keys", g.service_dir); + return svc_mkdir(path, k_svc_dacl_system, w); +} + +/* ---- profiles ---- */ + +/* Resolves one service_access entry (an account name) to a SID string. */ +typedef BOOL (WINAPI *pfn_LookupAccountNameW)(LPCWSTR system, LPCWSTR account, PSID sid, LPDWORD cb_sid, + LPWSTR domain, LPDWORD cch_domain, PSID_NAME_USE use); + +/* LookupAccountNameW is resolved at run time: it is not on the static floor list (tests/os-imports) */ +static int svc_resolve_entry(void *ctx, const char *entry, char *sid_out, size_t cap) +{ + static pfn_LookupAccountNameW lookup; + wchar_t wname[256], wdom[256], *wstr = NULL; + BYTE sidbuf[256]; + PSID sid = (PSID)sidbuf; + DWORD slen = sizeof(sidbuf), dlen = 256; + SID_NAME_USE use; + (void)ctx; + if (lookup == NULL) + lookup = (pfn_LookupAccountNameW)sd_platform_resolve_by_name(L"advapi32.dll", "LookupAccountNameW"); + if (lookup == NULL) + return 0; + wide_from_utf8(entry, wname, SD_COUNTOF(wname)); + if (!lookup(NULL, wname, sid, &slen, wdom, &dlen, &use)) + return 0; + if (!ConvertSidToStringSidW(sid, &wstr)) + return 0; + utf8_from_wide(wstr, sid_out, cap); + LocalFree(wstr); + return 1; +} + +/* A SID from its string form (UTF-8 in the resolved lists); LocalFree when done. */ +static PSID sid_of(const char *s) +{ + wchar_t w[SVC_SID_MAX]; + PSID sid = NULL; + wide_from_utf8(s, w, SD_COUNTOF(w)); + if (!ConvertStringSidToSidW(w, &sid)) + return NULL; + return sid; +} + +static void svc_free_profiles_locked(void) +{ + uint32_t i; + for (i = 0; i < g.nprofs; i++) { + sd_free(g.profs[i]); + g.profs[i] = NULL; + } + g.nprofs = 0; +} + +/* A machine profile is read through a checked handle (2.6 rule 1, TM-R27): every folder from the machine root to the file + and the file itself are checked (reparse point, owner, DACL) by svc_file_read_trusted, and only then parsed. A refused + or unreadable file is not loaded; the other profiles still load. */ +static sd_err svc_profile_read(const wchar_t *path, sd_profile *p, const char **bad) +{ + unsigned char *buf = NULL; + DWORD len = 0, e; + svc_folder_reason why = SVC_FOLDER_OK; + sd_ini *ini = NULL; + uint32_t flags = 0; + sd_err r; + e = svc_file_read_trusted(g.root, path, g.owner_trusted, NULL, &buf, &len, &why); + if (e != 0) { + *bad = "unreadable"; + return SD_E_NOT_FOUND; + } + if (why != SVC_FOLDER_OK) { + *bad = "refused: owner, reparse point, not a plain file or a writable ACL (TM-R27)"; + return SD_E_ACCESS_DENIED; + } + r = sd_ini_parse((const char *)buf, len, &ini, &flags); + sd_secure_free(buf, len); + if (r != SD_OK) { + *bad = "not INI"; + return r; + } + r = sd_profile_from_ini(ini, p, bad); + sd_ini_free(ini); + return r; +} + +/* Loads every machine profile of scope service. A profile that fails validation or name resolution is logged and not + loaded (2.6 rule 1: a profile is refused, the others still load). */ +static uint32_t svc_load_profiles(void) +{ + WIN32_FIND_DATAW fd; + HANDLE h; + wchar_t pat[MAX_PATH]; + svc_prof *list[SVC_MAX_PROFILES]; + uint32_t n = 0, i; + if (g.idle) + return 0; + _snwprintf_s(pat, MAX_PATH, _TRUNCATE, L"%s\\*.ini", g.profiles); + h = FindFirstFileW(pat, &fd); + if (h != INVALID_HANDLE_VALUE) { + do { + wchar_t path[MAX_PATH]; + svc_prof *x; + const char *bad = NULL, *why = NULL; + if (fd.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) + continue; + if (n >= SVC_MAX_PROFILES) + break; + _snwprintf_s(path, MAX_PATH, _TRUNCATE, L"%s\\%s", g.profiles, fd.cFileName); + x = (svc_prof *)sd_calloc(1, sizeof(*x)); + if (x == NULL) + break; + if (svc_profile_read(path, &x->p, &bad) != SD_OK) { + SD_LOGW("svc", "machine profile %ls not loaded: %s", fd.cFileName, bad ? bad : "unreadable"); + sd_free(x); + continue; + } + if (x->p.scope != SD_PROFILE_SCOPE_SERVICE) { + sd_free(x); /* users and user templates are not the service's business */ + continue; + } + if (x->p.id[0] == 0 || svc_validate_profile(&x->p, &bad, &why) != SD_OK) { + SD_LOGW("svc", "machine profile %ls not loaded: %s (%s)", fd.cFileName, bad ? bad : "no id", why ? why : ""); + sd_free(x); + continue; + } + if (svc_acl_build(&x->p, svc_resolve_entry, NULL, &x->acl) != SD_OK) { + SD_LOGW("svc", "machine profile %ls not loaded: service_access does not resolve (fail closed)", fd.cFileName); + sd_free(x); + continue; + } + { + uint32_t k; + int dup = 0; + for (k = 0; k < n; k++) + if (strcmp(list[k]->p.id, x->p.id) == 0) dup = 1; + if (dup) { + sd_free(x); + continue; + } + } + list[n++] = x; + } while (FindNextFileW(h, &fd)); + FindClose(h); + } + sd_mutex_lock(&g.lock); + svc_free_profiles_locked(); + for (i = 0; i < n; i++) + g.profs[g.nprofs++] = list[i]; + sd_mutex_unlock(&g.lock); + return n; +} + +/* Finds a profile by id (exact) or name (case-insensitive). Locked by the caller. */ +static svc_prof *find_profile_locked(const char *key) +{ + uint32_t i; + if (key == NULL || key[0] == 0) + return NULL; + for (i = 0; i < g.nprofs; i++) + if (strcmp(g.profs[i]->p.id, key) == 0) + return g.profs[i]; + for (i = 0; i < g.nprofs; i++) + if (_stricmp(g.profs[i]->p.name, key) == 0) + return g.profs[i]; + return NULL; +} + +/* ---- mount records ---- */ + +static svc_mount *find_by_id_locked(const char *id) +{ + uint32_t i; + for (i = 0; i < SVC_MAX_MOUNTS; i++) + if (g.mounts[i] && strcmp(g.mounts[i]->id, id) == 0) + return g.mounts[i]; + return NULL; +} + +static svc_mount *find_by_m_locked(sd_mount *m) +{ + uint32_t i; + if (m == NULL) + return NULL; + for (i = 0; i < SVC_MAX_MOUNTS; i++) + if (g.mounts[i] && g.mounts[i]->m == m) + return g.mounts[i]; + return NULL; +} + +/* A request key: mount_id, target ("X:" or "X:\") or profile (id or name). */ +static svc_mount *find_mount_locked(const sd_json *params) +{ + const char *id = sd_json_get_str(params, "mount_id"); + const char *tgt = sd_json_get_str(params, "target"); + const char *prof = sd_json_get_str(params, "profile"); + uint32_t i; + if (id) + return find_by_id_locked(id); + if (tgt && tgt[0] && (tgt[0] | 0x20) >= 'a' && (tgt[0] | 0x20) <= 'z' && tgt[1] == ':') { + wchar_t l = (wchar_t)(tgt[0] & ~0x20); + for (i = 0; i < SVC_MAX_MOUNTS; i++) + if (g.mounts[i] && g.mounts[i]->letter == l) + return g.mounts[i]; + return NULL; + } + if (prof) { + for (i = 0; i < SVC_MAX_MOUNTS; i++) + if (g.mounts[i] && g.mounts[i]->prof && + (strcmp(g.mounts[i]->prof->p.id, prof) == 0 || _stricmp(g.mounts[i]->prof->p.name, prof) == 0)) + return g.mounts[i]; + } + return NULL; +} + +static int letter_taken_locked(wchar_t l) +{ + uint32_t i; + for (i = 0; i < SVC_MAX_MOUNTS; i++) + if (g.mounts[i] && g.mounts[i]->letter == l) + return 1; + return 0; +} + +/* The drive letter an auto mount takes: the highest free letter from Z: down to D: (0 when none). */ +static wchar_t pick_letter_locked(void) +{ + DWORD used = GetLogicalDrives(); + wchar_t l; + for (l = L'Z'; l >= L'D'; l--) { + DWORD bit = 1ul << (l - L'A'); + if ((used & bit) == 0 && !letter_taken_locked(l)) + return l; + } + return 0; +} + +/* ---- identity of the caller (2.2, 03 §2.2 membership) ---- */ + +static void member_of(sd_ipc_client *c, const svc_acl *acl, svc_member *out) +{ + uint32_t i; + memset(out, 0, sizeof(*out)); + out->admin_elevated = caller_is_admin(c); + for (i = 0; i < acl->n_access && !out->in_access; i++) { + PSID sid = sid_of(acl->access[i]); + int mem = 0; + if (sid) { + if (sd_ipc_client_member_of(c, sid, &mem) == SD_OK && mem) + out->in_access = 1; + LocalFree(sid); + } + } + for (i = 0; i < acl->n_write && !out->in_write; i++) { + PSID sid = sid_of(acl->write[i]); + int mem = 0; + if (sid) { + if (sd_ipc_client_member_of(c, sid, &mem) == SD_OK && mem) + out->in_write = 1; + LocalFree(sid); + } + } +} + +static sd_err fail(sd_ipc_error *err, sd_err e, const char *code, const char *detail) +{ + sd_ipc_error_set(err, e, code, detail); + return err->err; +} + +static sd_err need_admin(sd_ipc_client *c, sd_ipc_error *err) +{ + if (!caller_is_admin(c)) + return fail(err, SD_E_ACCESS_DENIED, "access_denied", "an elevated administrator is required"); + return SD_OK; +} + +/* ---- broadcasts ---- */ + +static void broadcast_event(const char *topic, const char *event, sd_wr *data) +{ + sd_wr w; + sd_wr_init(&w, 1024); + if (sd_ipc_build_event(event, (const char *)data->p, data->len, &w) == SD_OK && g.srv != NULL) + sd_ipc_broadcast(g.srv, topic, (const char *)w.p, w.len); + sd_wr_free(&w); +} + +static void write_mount_event_data(sd_json_writer *jw, const char *id, wchar_t letter, const char *state) +{ + char tgt[4]; + target_of(letter, tgt); + sd_jw_obj_begin(jw); + sd_jw_key(jw, "mount_id"); sd_jw_cstr(jw, id); + sd_jw_key(jw, "target"); sd_jw_cstr(jw, tgt); + sd_jw_key(jw, "state"); sd_jw_cstr(jw, state); + sd_jw_obj_end(jw); +} + +/* Filtered mount.state (topic mount, 2.4): letter and state only, the same for every subscriber. */ +static void announce_state(const char *id, wchar_t letter, const char *state) +{ + sd_wr d; + sd_json_writer jw; + sd_wr_init(&d, 512); + sd_jw_init(&jw, &d); + write_mount_event_data(&jw, id, letter, state); + if (sd_jw_finish(&jw) == SD_OK) + broadcast_event("mount", "mount.state", &d); + sd_wr_free(&d); +} + +/* ---- sd_mount callbacks (control thread) ---- */ + +/* ---- Event Log (10.3) ---- */ + +static void svc_event(DWORD id, const wchar_t **args, size_t nargs) +{ + if (g.event != NULL) + g.event(g.event_ctx, id, args, nargs); +} + +/* Mount state events of a global drive: mounted, unmounted, and failed with the IPC code. Messages stay English. */ +static void svc_event_mount(sd_mount_state st, const char *prof_id, wchar_t letter, const char *err_code) +{ + wchar_t wprof[256], wtarget[4], wcode[64]; + const wchar_t *args[3]; + wide_from_utf8(prof_id, wprof, SD_COUNTOF(wprof)); + wtarget[0] = letter; + wtarget[1] = L':'; + wtarget[2] = 0; + args[0] = wprof; + args[1] = wtarget; + if (st == SD_MOUNT_MOUNTED) { + svc_event(SVC_MSG_MOUNTED, args, 2); + } else if (st == SD_MOUNT_UNMOUNTED) { + svc_event(SVC_MSG_UNMOUNTED, args, 2); + } else if (st == SD_MOUNT_FAILED) { + wide_from_utf8(err_code, wcode, SD_COUNTOF(wcode)); + args[2] = wcode; + svc_event(SVC_MSG_MOUNT_FAILED, args, 3); + } +} + +/* Event 3102 (10.3): a machine folder refused at start. %1 is the folder, %2 the reason (owner, reparse_point, + not_directory, acl). The service stays idle and nothing is repaired here (repair-data does that). */ +static void svc_event_untrusted(const svc_refusal *w) +{ + const wchar_t *args[2]; + args[0] = w->path; + args[1] = svc_folder_reason_text(w->why); + svc_event(SVC_MSG_UNTRUSTED_FOLDER, args, 2); +} + +/* A key file of a service profile refused by the trust rules (TM-R27): the same Event 3102 with the file as %1. The + mount then fails with the usage error; the service stays up. */ +static void svc_event_key_refused(const wchar_t *path, svc_folder_reason why) +{ + const wchar_t *args[2]; + args[0] = path; + args[1] = svc_folder_reason_text(why); + svc_event(SVC_MSG_UNTRUSTED_FOLDER, args, 2); +} + +/* The SCM sink: ReportEventW under source ShellDrive (registered by `shelldrive service install`). The message file + is shelldrive-host.exe itself, so the ids of svc_events.mc resolve in the Application log. */ +static void svc_eventlog_write(void *ctx, DWORD id, const wchar_t **args, size_t nargs) +{ + HANDLE h; + WORD type; + (void)ctx; + switch ((id >> 30) & 3u) { + case 3u: type = EVENTLOG_ERROR_TYPE; break; + case 2u: type = EVENTLOG_WARNING_TYPE; break; + case 1u: type = EVENTLOG_INFORMATION_TYPE; break; + default: type = EVENTLOG_SUCCESS; break; + } + h = RegisterEventSourceW(NULL, SVC_SCM_EVENT_SOURCE); + if (h == NULL) + return; + (void)ReportEventW(h, type, 0, id, NULL, (WORD)nargs, 0, args, NULL); + DeregisterEventSource(h); +} + +static void svc_on_state(void *ctx, sd_mount *m, sd_mount_state st, sd_err why) +{ + char id[16] = "", state[24], prof_id[256] = "", err_code[64] = ""; + wchar_t letter = 0; + svc_mount *r; + const char *code = NULL; + (void)ctx; + sd_mutex_lock(&g.lock); + r = find_by_m_locked(m); + if (r) { + r->st = st; + strcpy_s(id, sizeof(id), r->id); + letter = r->letter; + if (r->prof != NULL) + strcpy_s(prof_id, sizeof(prof_id), r->prof->p.id); + if (st == SD_MOUNT_FAILED || st == SD_MOUNT_UNMOUNTED) { + r->final = 1; + if (st == SD_MOUNT_FAILED) { + code = r->prompt_refused ? (r->refuse_code ? r->refuse_code : "prompt_unavailable") : sd_err_name(why); + strcpy_s(r->err_code, sizeof(r->err_code), code); + strcpy_s(r->err_detail, sizeof(r->err_detail), "mount failed"); + strcpy_s(err_code, sizeof(err_code), code); + } + } + } + sd_mutex_unlock(&g.lock); + if (r == NULL) + return; + strcpy_s(state, sizeof(state), sd_mount_state_name(st)); + SD_LOGI("svc", "mount %s: %s (%s)", id, state, sd_err_name(why)); + announce_state(id, letter, state); + svc_event_mount(st, prof_id, letter, err_code); +} + +/* ---- attention (2.6 rule 3) ---- */ + +/* Number of open routed prompts. Takes g.lock: never call it with g.lock held. */ +static uint32_t svc_open_prompts(void) +{ + uint32_t i, n = 0; + sd_mutex_lock(&g.lock); + for (i = 0; i < SVC_MAX_PROMPTS; i++) + if (g.prompts[i].used) + n++; + sd_mutex_unlock(&g.lock); + return n; +} + +/* Builds the attention event {count} into w (initialized by the caller). Content-free: only the number. */ +static sd_err build_attention(uint32_t count, sd_wr *w) +{ + sd_wr d; + sd_json_writer jw; + sd_err e = SD_E_FAILURE; + sd_wr_init(&d, 64); + sd_jw_init(&jw, &d); + sd_jw_obj_begin(&jw); + sd_jw_key(&jw, "count"); + sd_jw_int(&jw, (int64_t)count); + sd_jw_obj_end(&jw); + if (sd_jw_finish(&jw) == SD_OK) + e = sd_ipc_build_event("attention", (const char *)d.p, d.len, w); + sd_wr_free(&d); + return e; +} + +/* Sent whenever the number of open prompts changes, 0 included, so an admin UI clears its state. Not with g.lock held. */ +static void svc_broadcast_attention(void) +{ + sd_wr w; + sd_wr_init(&w, 128); + if (g.srv != NULL && build_attention(svc_open_prompts(), &w) == SD_OK) + sd_ipc_broadcast(g.srv, "attention", (const char *)w.p, w.len); + sd_wr_free(&w); +} + +/* Hands the prompt to an elevated administrator that subscribed to `prompt` (2.5, 2.6 rule 3); refuses it otherwise. */ +static void svc_on_prompt(void *ctx, sd_mount *m, const sd_mount_prompt *p) +{ + svc_prompt *slot = NULL; + svc_mount *r; + uint32_t i, subs; + int refuse = 0; + uint32_t pid = 0; + (void)ctx; + /* 2.6 rule 2: there is no trust on first use for the service. An unknown host key is never accepted here; the admin + pins it first (hostkey add --machine --from-server), so the mount fails with host_key_unknown (exit 5). */ + subs = (g.srv && p->kind != SD_MP_HOSTKEY) ? sd_ipc_topic_subscribers(g.srv, "prompt", SD_IPC_CAP_PROMPTS_UI) : 0; + sd_mutex_lock(&g.lock); + r = find_by_m_locked(m); + if (r == NULL || subs == 0) { + if (r) { + r->prompt_refused = 1; + r->refuse_code = (p->kind == SD_MP_HOSTKEY) ? "host_key_unknown" : "prompt_unavailable"; + } + refuse = 1; + } else { + for (i = 0; i < SVC_MAX_PROMPTS && slot == NULL; i++) + if (!g.prompts[i].used) + slot = &g.prompts[i]; + if (slot == NULL) { + r->prompt_refused = 1; + refuse = 1; + } else { + memset(slot, 0, sizeof(*slot)); + slot->used = 1; + slot->id = ++g.next_prompt; + slot->mount = r; + slot->mprompt = p->id; + slot->deadline = svc_now() + SVC_PROMPT_MS; + slot->kind = (int)p->kind; + strcpy_s(slot->mount_id, sizeof(slot->mount_id), r->id); + strcpy_s(slot->profile_id, sizeof(slot->profile_id), r->prof->p.id); + strcpy_s(slot->host, sizeof(slot->host), p->host); + slot->port = p->port; + strcpy_s(slot->user, sizeof(slot->user), p->user); + slot->is_reconnect = p->is_reconnect; + slot->can_save = p->can_save; + pid = slot->id; + } + } + sd_mutex_unlock(&g.lock); + if (refuse) { + SD_LOGW("svc", "a prompt (%s) has no elevated administrator to answer it; the mount fails", sd_mount_prompt_kind_name(p->kind)); + (void)sd_mount_answer(m, p->id, SD_MA_REJECT, 0, NULL); + return; + } + { + sd_wr d; + sd_json_writer jw; + char pidstr[24]; + _snprintf_s(pidstr, sizeof(pidstr), _TRUNCATE, "p-%u", (unsigned)pid); + sd_wr_init(&d, 1024); + sd_jw_init(&jw, &d); + sd_jw_obj_begin(&jw); + sd_jw_key(&jw, "prompt_id"); sd_jw_cstr(&jw, pidstr); + sd_jw_key(&jw, "mount_id"); sd_jw_cstr(&jw, slot->mount_id); + sd_jw_key(&jw, "profile_id"); sd_jw_cstr(&jw, slot->profile_id); + sd_jw_key(&jw, "kind"); sd_jw_cstr(&jw, sd_mount_prompt_kind_name(p->kind)); + sd_jw_key(&jw, "host"); sd_jw_cstr(&jw, slot->host); + sd_jw_key(&jw, "port"); sd_jw_int(&jw, slot->port); + sd_jw_key(&jw, "user"); sd_jw_cstr(&jw, slot->user); + sd_jw_key(&jw, "is_reconnect"); sd_jw_bool(&jw, slot->is_reconnect); + sd_jw_key(&jw, "can_save"); sd_jw_bool(&jw, 0); /* the service never saves a secret it was given */ + sd_jw_key(&jw, "queued"); sd_jw_bool(&jw, 0); + sd_jw_key(&jw, "deadline_ms"); sd_jw_int(&jw, SVC_PROMPT_MS); + sd_jw_obj_end(&jw); + if (sd_jw_finish(&jw) == SD_OK) { + broadcast_event("prompt", "prompt.request", &d); + svc_broadcast_attention(); + } + sd_wr_free(&d); + } +} + +static void svc_on_prompt_closed(void *ctx, sd_mount *m, uint32_t prompt_id) +{ + uint32_t i; + char pidstr[24]; + char mid[16] = ""; + int closed = 0; + (void)ctx; + sd_mutex_lock(&g.lock); + for (i = 0; i < SVC_MAX_PROMPTS; i++) { + svc_prompt *s = &g.prompts[i]; + if (s->used && s->mount && s->mount->m == m && s->mprompt == prompt_id) { + s->used = 0; + strcpy_s(mid, sizeof(mid), s->mount_id); + _snprintf_s(pidstr, sizeof(pidstr), _TRUNCATE, "p-%u", (unsigned)s->id); + closed = 1; + break; + } + } + sd_mutex_unlock(&g.lock); + if (closed) { + sd_wr d; + sd_json_writer jw; + sd_wr_init(&d, 256); + sd_jw_init(&jw, &d); + sd_jw_obj_begin(&jw); + sd_jw_key(&jw, "prompt_id"); sd_jw_cstr(&jw, pidstr); + sd_jw_key(&jw, "mount_id"); sd_jw_cstr(&jw, mid); + sd_jw_key(&jw, "reason"); sd_jw_cstr(&jw, "answered"); + sd_jw_obj_end(&jw); + if (sd_jw_finish(&jw) == SD_OK) + broadcast_event("prompt", "prompt.closed", &d); + sd_wr_free(&d); + svc_broadcast_attention(); + } +} + +static int svc_secret_get(void *ctx, sd_mount *m, int kind, const char *target, char *out, size_t cap) +{ + sd_secbuf *b = NULL; + int ok = 0; + (void)ctx; + (void)m; + if (sd_secret_get(SD_SECRET_SERVICE, (sd_secret_kind)kind, target, &b) == SD_OK) { + if (b->len + 1 <= cap) { + memcpy(out, b->p, b->len); + out[b->len] = 0; + ok = 1; + } + sd_secbuf_free(b); + } + return ok; +} + +static void svc_on_warning(void *ctx, sd_mount *m, const char *code, uint32_t hop, const char *detail) +{ + (void)ctx; + (void)m; + SD_LOGW("svc", "mount warning %s (hop %u) %s", code ? code : "", (unsigned)hop, detail ? detail : ""); +} + +static void svc_on_write_failed(void *ctx, sd_mount *m, const wchar_t *path, UINT64 bytes, const char *status) +{ + (void)ctx; + (void)m; + (void)path; + SD_LOGW("svc", "write-behind data lost (%llu bytes, %s)", (unsigned long long)bytes, status ? status : ""); +} + +/* Event 1200 (03 10.3, TM-R24): a successful change through a global drive, at info. %1 profile, %2 requestor SID, + %3 account name (DOMAIN\name, or the SID alone when it does not resolve), %4 operation, %5 remote path. The vfs has + already coalesced repeats per 60 s. The lookup runs on the control thread; the SID is valid during the callback. */ +static void svc_on_audit(void *ctx, sd_mount *m, PSID sid, const char *op, const wchar_t *path) +{ + char prof_id[256] = ""; + wchar_t sidw[128] = L"", acct[300] = L"", name[128] = L"", dom[128] = L"", wprof[256] = L"", wop[48] = L""; + LPWSTR sidstr = NULL; + DWORD name_len = (DWORD)SD_COUNTOF(name), dom_len = (DWORD)SD_COUNTOF(dom); + SID_NAME_USE use; + svc_mount *r; + const wchar_t *args[5]; + (void)ctx; + if (sid == NULL) + return; + sd_mutex_lock(&g.lock); + r = find_by_m_locked(m); + if (r != NULL && r->prof != NULL) + strcpy_s(prof_id, sizeof(prof_id), r->prof->p.id); + sd_mutex_unlock(&g.lock); + if (ConvertSidToStringSidW(sid, &sidstr)) { + wcsncpy_s(sidw, SD_COUNTOF(sidw), sidstr, _TRUNCATE); + LocalFree(sidstr); + } + if (LookupAccountSidW(NULL, sid, name, &name_len, dom, &dom_len, &use)) { + if (dom[0] != 0) { + wcsncpy_s(acct, SD_COUNTOF(acct), dom, _TRUNCATE); + wcsncat_s(acct, SD_COUNTOF(acct), L"\\", _TRUNCATE); + } + wcsncat_s(acct, SD_COUNTOF(acct), name, _TRUNCATE); + } else { + wcsncpy_s(acct, SD_COUNTOF(acct), sidw, _TRUNCATE); + } + wide_from_utf8(prof_id, wprof, SD_COUNTOF(wprof)); + wide_from_utf8(op ? op : "", wop, SD_COUNTOF(wop)); + args[0] = wprof; + args[1] = sidw; + args[2] = acct; + args[3] = wop; + args[4] = path ? path : L""; + svc_event(SVC_MSG_AUDIT, args, 5); +} + +static void svc_on_notice(void *ctx, sd_mount *m, const char *code, const char *detail) +{ + (void)ctx; + (void)m; + SD_LOGI("svc", "notice %s %s", code ? code : "", detail ? detail : ""); +} + +/* ---- chains of service profiles (M5, 2.6 rule 1, 3.4.1, 3.8) ---- + + A machine profile's chain is resolved with sd_chain_resolve over the machine profiles only (a jump names a service + profile, never a user one). Every hop and every proxy leg then follows the service rules: the machine known_hosts + store only (set by the callers, read-only), no TOFU (a host-key prompt is refused, 2.6 rule 2), one identity file + under the service folder read through a checked handle (TM-R27) and handed over as a key blob, no agent and no + keyboard-interactive, and every secret from the service secret store. The arena holds the strings and key bytes + sd_mount_create copies; it is wiped with svc_chain_free. */ +typedef struct svc_chain { + uint32_t count; + sd_mount_hop hop[SD_MOUNT_MAX_HOPS]; + wchar_t host[SD_MOUNT_MAX_HOPS][256]; + char user[SD_MOUNT_MAX_HOPS][128]; + char alias[SD_MOUNT_MAX_HOPS][256]; + char label[SD_MOUNT_MAX_HOPS][MAX_PATH * 3]; + unsigned char *blob[SD_MOUNT_MAX_HOPS]; + DWORD blob_len[SD_MOUNT_MAX_HOPS]; + wchar_t proxy_host[SD_MOUNT_MAX_HOPS][256]; + char proxy_user[SD_MOUNT_MAX_HOPS][128]; +} svc_chain; + +static void svc_chain_free(svc_chain *c) +{ + uint32_t i; + if (c == NULL) + return; + for (i = 0; i < SD_MOUNT_MAX_HOPS; i++) + if (c->blob[i]) + sd_secure_free(c->blob[i], c->blob_len[i]); + sd_free(c); +} + +/* proxy_auth of a service leg (3.8), as the per-user agent maps it. */ +static uint32_t svc_proxy_auth_of(const sd_proxy_spec *x, int pa) +{ + if (x->kind == SD_PROFILE_PROXY_SOCKS5) + return x->user[0] ? SD_PROXY_AUTH_USERPASS : 0; + switch (pa) { + case SD_PROFILE_PROXY_AUTH_BASIC: return SD_PROXY_AUTH_BASIC; + case SD_PROFILE_PROXY_AUTH_NEGOTIATE: return SD_PROXY_AUTH_SSPI | (x->user[0] ? SD_PROXY_AUTH_BASIC : 0); + case SD_PROFILE_PROXY_AUTH_NONE: return 0; + default: return SD_PROXY_AUTH_SSPI_KERBEROS | (x->user[0] ? SD_PROXY_AUTH_BASIC : 0); + } +} + +/* Lookup of sd_chain_resolve: the machine profiles only. The control thread calls it without g.lock held. */ +static sd_err svc_chain_lookup(void *ctx, const char *key, sd_profile *out) +{ + svc_prof *x; + (void)ctx; + sd_mutex_lock(&g.lock); + x = find_profile_locked(key); + if (x != NULL) + *out = x->p; + sd_mutex_unlock(&g.lock); + return x != NULL ? SD_OK : SD_E_NOT_FOUND; +} + +/* Whether the service secret store holds the proxy password of this leg (sd_mount reads it with secret_get later). */ +static int svc_proxy_secret_present(const sd_proxy_spec *x) +{ + char target[SD_SECRET_MAX_TARGET]; + sd_secbuf *b = NULL; + int ok = 0; + if (x->user[0] == 0) + return 0; + if (sd_secret_target_proxy(x->kind == SD_PROFILE_PROXY_SOCKS5 ? "socks5" : "http", x->user, x->host, x->port, target, + sizeof(target)) != SD_OK) + return 0; + if (sd_secret_get(SD_SECRET_SERVICE, SD_SECRET_PROXY, target, &b) == SD_OK) { + ok = 1; + sd_secbuf_free(b); + } + return ok; +} + +/* The identity file of one hop (TM-R27): the key blob read through a checked handle; the bytes stay in the arena. */ +static const char *svc_chain_identity(svc_chain *c, uint32_t i, const sd_chain_hop *s) +{ + wchar_t identw[MAX_PATH]; + svc_folder_reason fr = SVC_FOLDER_OK; + DWORD fe; + sd_mount_hop *d = &c->hop[i]; + if (s->identity_count == 0) + return NULL; + if (sd_config_expand_path(s->identity[0], identw, SD_COUNTOF(identw)) != SD_OK || + !svc_path_under(identw, g.service_dir)) + return "identity file outside the service folder (TM-R27)"; + fe = svc_file_read_trusted(g.service_dir, identw, g.owner_trusted, NULL, &c->blob[i], &c->blob_len[i], &fr); + if (fe != 0) + return "identity file cannot be read (TM-R27)"; + if (fr != SVC_FOLDER_OK) { + svc_event_key_refused(identw, fr); + return "identity file refused: owner, reparse point, not a plain file or a writable ACL (TM-R27)"; + } + utf8_from_wide(identw, c->label[i], sizeof(c->label[i])); + d->key_blob = c->blob[i]; + d->key_blob_len = c->blob_len[i]; + d->key_label = c->label[i]; + return NULL; +} + +static int svc_sysproxy_allowed(void); +static int svc_sysproxy_sspi(void); + +/* The leg that reaches hop i (3.8): none, a configured HTTP or SOCKS5 proxy (a service secret for a user), or the + machine configuration read through the seam (proxy = system, refused unless the machine policy allows it). */ +static const char *svc_chain_leg(svc_chain *c, uint32_t i, const sd_chain *ch) +{ + const sd_proxy_spec *x = &ch->proxy[i]; + sd_mount_hop *d = &c->hop[i]; + int pa = ch->proxy_auth[i]; + int fb = (ch->proxy_fallback_direct & (1u << i)) != 0; + const char *bad = NULL, *why = NULL; + if (x->kind == SD_PROFILE_PROXY_NONE) + return NULL; + if (x->kind == SD_PROFILE_PROXY_SYSTEM) { + int kind = SD_PROXY_NONE; + wchar_t hostw[256]; + uint16_t port = 0; + sd_err e; + int sspi = 0; + if (g.sysproxy == NULL || g.sysproxy->read == NULL || !svc_sysproxy_allowed()) + return "proxy = system is refused by policy AllowSystemProxy (TM-R10)"; + sspi = svc_sysproxy_sspi(); + if (svc_proxy_leg_check(x, pa, 0, &bad, &why) != SD_OK) { + SD_LOGW("svc", "chain leg %u refused: %s (%s)", (unsigned)i, bad, why); + return why; + } + e = g.sysproxy->read(g.sysproxy->ctx, c->host[i], d->port, &kind, hostw, &port); + if (e != SD_OK) + return fb ? NULL : "the system proxy cannot be read (TM-R10)"; + if (kind == SD_PROXY_NONE) + return NULL; + if (kind != SD_PROXY_HTTP && kind != SD_PROXY_SOCKS5) + return "the system proxy is not usable"; + wcscpy_s(c->proxy_host[i], SD_COUNTOF(c->proxy_host[i]), hostw); + d->proxy.kind = kind; + d->proxy.host = c->proxy_host[i]; + d->proxy.port = port; + d->proxy.user = NULL; + /* TM-R10: no SSPI towards a proxy obtained from proxy = system unless AllowSystemProxySspi = 1; then the + Kerberos-only Negotiate of proxy_auth auto (no credentials here, 2.6 rule 1) */ + d->proxy.auth = (sspi && pa != SD_PROFILE_PROXY_AUTH_NONE) ? SD_PROXY_AUTH_SSPI_KERBEROS : 0; + d->proxy.allow_discovered_sspi = sspi; + d->proxy.allow_system = 0; + d->proxy.fallback_direct = fb; + return NULL; + } + wide_from_utf8(x->host, c->proxy_host[i], SD_COUNTOF(c->proxy_host[i])); + if (svc_proxy_leg_check(x, pa, svc_proxy_secret_present(x), &bad, &why) != SD_OK) { + SD_LOGW("svc", "chain leg %u refused: %s (%s)", (unsigned)i, bad, why); + return why; + } + d->proxy.kind = x->kind; + d->proxy.host = c->proxy_host[i]; + d->proxy.port = x->port; + if (x->user[0]) { + strcpy_s(c->proxy_user[i], sizeof(c->proxy_user[i]), x->user); + d->proxy.user = c->proxy_user[i]; + } + d->proxy.auth = svc_proxy_auth_of(x, pa); + d->proxy.fallback_direct = 0; + return NULL; +} + +/* One hop with the service rules (svc_chain_hop_check). hku is the host key mode of the target; the jump hosts + never update host keys (no announcements from them). */ +static const char *svc_chain_hop(svc_chain *c, uint32_t i, const sd_chain *ch, const sd_profile *p, int hku) +{ + const sd_chain_hop *s = &ch->hop[i]; + sd_mount_hop *d = &c->hop[i]; + const char *bad = NULL, *why = NULL; + uint32_t methods = 0; + if (svc_chain_hop_check(s, &bad, &why) != SD_OK) { + SD_LOGW("svc", "chain hop %u refused: %s (%s)", (unsigned)i, bad, why); + return why; + } + wide_from_utf8(s->host, c->host[i], SD_COUNTOF(c->host[i])); + if (s->auth_methods & SD_PROFILE_AUTH_PUBLICKEY) methods |= SD_AUTH_PUBLICKEY; + if (s->auth_methods & SD_PROFILE_AUTH_PASSWORD) methods |= SD_AUTH_PASSWORD; + strcpy_s(c->user[i], sizeof(c->user[i]), s->user[0] ? s->user : p->user); + if (s->host_key_alias[0]) { + strcpy_s(c->alias[i], sizeof(c->alias[i]), s->host_key_alias); + d->host_key_alias = c->alias[i]; + } + d->host = c->host[i]; + d->port = s->port ? s->port : 22; + d->user = c->user[i]; + d->auth_methods = methods; + d->agents = 0; /* D17: no agent in the service */ + d->agent_keys = NULL; + d->identities = NULL; + d->identity_count = 0; + d->identities_only = s->identities_only; /* D37: resolved by sd_chain_resolve */ + d->certificate = NULL; + d->update_host_keys = (i + 1 == ch->count) ? hku : SD_HKU_NO; + d->no_save = 1; /* the service never keeps a secret it was given (2.5) */ + why = svc_chain_identity(c, i, s); + if (why != NULL) + return why; + return svc_chain_leg(c, i, ch); +} + +/* Builds the hops of a machine profile (the last one is the target). Returns NULL with *out set, or the refusal + (*out is NULL). Control thread only, without g.lock. */ +static const char *svc_chain_build(svc_chain **out, const sd_profile *p, int hku) +{ + svc_chain *c; + sd_chain *ch = NULL; + const char *why = NULL; + uint32_t i; + sd_err e; + *out = NULL; + c = (svc_chain *)sd_calloc(1, sizeof(*c)); + if (c == NULL) + return "out of memory"; + e = sd_chain_resolve(p, svc_chain_lookup, NULL, &ch, &why); + if (e != SD_OK || ch == NULL || ch->count == 0 || ch->count > SD_MOUNT_MAX_HOPS) { + sd_free(c); + sd_free(ch); + return why ? why : "the jump hosts cannot be resolved"; + } + c->count = ch->count; + why = NULL; /* a resolved chain: the hop checks below decide */ + for (i = 0; i < ch->count && why == NULL; i++) + why = svc_chain_hop(c, i, ch, p, hku); + sd_free(ch); + if (why != NULL) { + svc_chain_free(c); + return why; + } + *out = c; + return NULL; +} + +/* Machine policy and the real read of proxy = system (3.8). The seam is svc_opts.sysproxy_ops. */ +static sd_err svc_sysproxy_machine_read(void *ctx, const wchar_t *host, uint16_t port, int *kind, wchar_t *host_out, + uint16_t *port_out) +{ + sd_proxy_kind k = SD_PROXY_NONE; + sd_err e; + (void)ctx; + e = sd_ssh_proxy_machine(host, port, &k, host_out, port_out); + *kind = (int)k; + return e; +} + +static const svc_sysproxy_ops k_sysproxy_shipping = { -1, svc_sysproxy_machine_read, NULL }; + +/* One machine policy of proxy = system (03 §3.5, 3.8): HKLM only, a missing or other-typed value reads as 0. */ +static int svc_sysproxy_policy_flag(sd_policy_id id) +{ + DWORD v = 0; + return sd_policy_machine_get_dword(id, &v) && v == 1; +} + +/* AllowSystemProxy for one leg: the seam's own answer, or the machine policy when the seam says -1. */ +static int svc_sysproxy_allowed(void) +{ +#ifdef SD_SVC_TEST_HOOKS + if (g.test_sysproxy_forced) + return g.test_sysproxy_allow == 1; +#endif + if (g.sysproxy->allow >= 0) + return g.sysproxy->allow != 0; + return svc_sysproxy_policy_flag(SD_POLICY_ALLOWSYSTEMPROXY); +} + +/* AllowSystemProxySspi (TM-R10): SSPI towards a system proxy only when the seam asks for the policy and it is 1. */ +static int svc_sysproxy_sspi(void) +{ +#ifdef SD_SVC_TEST_HOOKS + if (g.test_sysproxy_forced) + return g.test_sysproxy_sspi == 1; +#endif + if (g.sysproxy->allow >= 0) + return 0; + return svc_sysproxy_policy_flag(SD_POLICY_ALLOWSYSTEMPROXYSSPI); +} + +/* ---- host keys kept for review (M5, 03 §5.6, §6.1) ---- */ + +static void write_keys_json(sd_json_writer *jw, const char *key, const sd_mount_hostkey *k, uint32_t n) +{ + uint32_t i; + sd_jw_key(jw, key); + sd_jw_arr_begin(jw); + for (i = 0; i < n; i++) { + sd_jw_obj_begin(jw); + sd_jw_key(jw, "type"); sd_jw_cstr(jw, k[i].type); + sd_jw_key(jw, "fingerprint"); sd_jw_cstr(jw, k[i].fp_sha256); + sd_jw_obj_end(jw); + } + sd_jw_arr_end(jw); +} + +/* One announcement as hostkey.announced.list and the hostkeys.announced event show it (5.6). drop_adds: the additions + were already written to the machine store, so only the removals are shown (no copy of the record is made). */ +static void write_ann_obj(sd_json_writer *jw, const char *id, const char *mount_id, const sd_mount_announcement *a, + int drop_adds) +{ + sd_jw_obj_begin(jw); + sd_jw_key(jw, "id"); sd_jw_cstr(jw, id); + sd_jw_key(jw, "mount_id"); sd_jw_cstr(jw, mount_id); + sd_jw_key(jw, "host"); sd_jw_cstr(jw, a->host); + sd_jw_key(jw, "lookup_name"); sd_jw_cstr(jw, a->lookup_name); + sd_jw_key(jw, "hop"); sd_jw_int(jw, (int64_t)a->hop_index + 1); + write_keys_json(jw, "add", a->add, drop_adds ? 0u : a->add_count); + write_keys_json(jw, "remove", a->remove, a->remove_count); + sd_jw_obj_end(jw); +} + +/* Keeps an announcement for review, newest first. When SVC_MAX_ANNOUNCE are kept the oldest is dropped. The id is copied + to id_out under g.lock. drop_adds keeps the removals only (the additions were stored). Returns 0 only when the record + cannot be allocated. */ +static int ann_keep(const char *mount_id, const sd_mount_announcement *a, int drop_adds, char *id_out, size_t cap) +{ + svc_ann *x = (svc_ann *)sd_calloc(1, sizeof(*x)); + svc_ann *last, *prev = NULL; + if (x == NULL) + return 0; + x->a = *a; + if (drop_adds) + x->a.add_count = 0; + strcpy_s(x->mount_id, sizeof(x->mount_id), mount_id ? mount_id : ""); + sd_mutex_lock(&g.lock); + g.next_ann++; + _snprintf_s(x->id, sizeof(x->id), _TRUNCATE, "a-%u", (unsigned)g.next_ann); + x->next = g.anns; + g.anns = x; + g.nann++; + if (g.nann > SVC_MAX_ANNOUNCE) { + for (last = g.anns; last->next != NULL; last = last->next) + prev = last; + if (prev != NULL) prev->next = NULL; else g.anns = NULL; + g.nann--; + SD_LOGW("svc", "%u host key announcements are kept: the oldest one (%s) was dropped", (unsigned)SVC_MAX_ANNOUNCE, + last->id); + sd_free(last); + } + strcpy_s(id_out, cap, x->id); + sd_mutex_unlock(&g.lock); + return 1; +} + +/* The fingerprints of n keys, comma separated, into out ("-" when there are none). */ +static void join_fps(const sd_mount_hostkey *k, uint32_t n, wchar_t *out, size_t cch) +{ + uint32_t i; + out[0] = 0; + for (i = 0; i < n && i < SD_MOUNT_ANNOUNCE_MAX; i++) { + wchar_t w[96]; + if (i) + wcsncat_s(out, cch, L", ", _TRUNCATE); + wide_from_utf8(k[i].fp_sha256, w, SD_COUNTOF(w)); + wcsncat_s(out, cch, w, _TRUNCATE); + } + if (out[0] == 0) + wcsncpy_s(out, cch, L"-", _TRUNCATE); +} + +/* Event 2300 (10.3): %1 host, %2 the new fingerprints, %3 the ones no longer offered. */ +static void svc_event_announced(const sd_mount_announcement *a, int drop_adds) +{ + wchar_t host[256], added[512], removed[512]; + const wchar_t *args[3]; + wide_from_utf8(a->host, host, SD_COUNTOF(host)); + join_fps(a->add, drop_adds ? 0u : a->add_count, added, SD_COUNTOF(added)); + join_fps(a->remove, a->remove_count, removed, SD_COUNTOF(removed)); + args[0] = host; + args[1] = added; + args[2] = removed; + svc_event(SVC_MSG_HOSTKEYS_ANNOUNCED, args, 3); +} + +static sd_err svc_machine_apply(const sd_mount_announcement *a, int add_only); + +/* The machine known_hosts is read by the service's mounts and hostkey connections (5.1): it is checked on its handle + before the read, as a write is (svc_store_check, TM-R27), so a file an untrusted principal can change is never the + source of the trusted keys. A missing file is not a refusal (no stored key). Returns NULL, or the refusal text. Only + the check on the handle: the mount opens the file again, so a change between the two is not excluded (open item). */ +static const char *svc_kh_read_check(void) +{ + svc_folder_reason why = SVC_FOLDER_OK; + DWORD e; + if (g.kh[0] == 0) + return "machine known_hosts path is not set"; + e = svc_store_check(g.kh, g.owner_trusted, NULL, &why); + if (e != 0 || why != SVC_FOLDER_OK) { + SD_LOGW("svc", "the machine known_hosts is refused for reading (win32 %u, reason %d) (TM-R27)", (unsigned)e, + (int)why); + return "machine known_hosts refused: owner, reparse point, not a plain file or a writable ACL (TM-R27)"; + } + return NULL; +} + +/* The UpdateHostKeys policy of the machine (03 §3.5): 2 lets the service add the proven keys of an announcement to the + machine store by itself (5.6). HKLM wins over HKCU, as for every policy. Read at each announcement, so a change + applies to the next one. Tests force the value (svc_test_hk_policy). */ +static int svc_policy_hk_accept(void) +{ +#ifdef SD_SVC_TEST_HOOKS + if (g.test_hk_policy_forced) + return g.test_hk_policy == 2; +#endif + { + DWORD v = 0; + return sd_policy_machine_get_dword(SD_POLICY_UPDATEHOSTKEYS, &v) && v == 2; + } +} + +#ifdef SD_SVC_TEST_HOOKS +void svc_test_hk_policy(int forced, DWORD value) +{ + g.test_hk_policy_forced = forced; + g.test_hk_policy = value; +} + +void svc_test_connect_admin(int on) +{ + g.test_connect_admin = on; +} + +#ifdef SD_SVC_TEST_HOOKS +void svc_test_admin_all_clients(int on) +{ + g.test_admin_any = on; +} +#endif + +void svc_test_sysproxy_policy(int forced, DWORD allow, DWORD sspi) +{ + g.test_sysproxy_forced = forced; + g.test_sysproxy_allow = allow; + g.test_sysproxy_sspi = sspi; +} +#endif + +/* UpdateHostKeys (5.6) of a global mount, policy ask: the announcement is kept for an administrator and announced + (Event 2300, topic hostkeys). Under policy 2 (svc_policy_hk_accept) the proven new keys are written to the machine + store first, with the same checks as an accept; the removals always wait for an administrator (as in the agent). + A failed write keeps the whole announcement for review. */ +static void svc_on_hostkeys(void *ctx, sd_mount *m, const sd_mount_announcement *a, int applied, sd_err err) +{ + char mid[16] = "", id[16] = ""; + int drop_adds = 0; + svc_mount *r; + sd_wr d; + sd_json_writer jw; + (void)ctx; + (void)err; + if (applied) { + /* a service mount never applies an announcement itself (its hop is ask), so this is a fault: nothing is changed */ + SD_LOGW("svc", "an applied host key announcement was reported for a service mount; nothing is changed"); + return; + } + if (a->add_count != 0 && svc_policy_hk_accept()) { + sd_err ae = svc_machine_apply(a, 1); /* add only: removals need the review (5.6) */ + SD_LOGI("svc", "UpdateHostKeys policy 2: %u new key(s) for %Hs saved to the machine store (%s)", + (unsigned)a->add_count, a->lookup_name, sd_err_name(ae)); + if (ae == SD_OK) { + if (a->remove_count == 0) + return; + drop_adds = 1; /* what is left to review: the removals */ + } + } + sd_mutex_lock(&g.lock); + r = find_by_m_locked(m); + if (r != NULL) + strcpy_s(mid, sizeof(mid), r->id); + sd_mutex_unlock(&g.lock); + if (!ann_keep(mid, a, drop_adds, id, sizeof(id))) { + SD_LOGW("svc", "host keys announced for %Hs were not kept (out of memory)", a->lookup_name); + return; + } + SD_LOGI("svc", "host keys announced for %Hs kept for review (%s)", a->lookup_name, id); + svc_event_announced(a, drop_adds); + sd_wr_init(&d, 4096); + sd_jw_init(&jw, &d); + write_ann_obj(&jw, id, mid, a, drop_adds); + if (sd_jw_finish(&jw) == SD_OK) + broadcast_event("hostkeys", "hostkeys.announced", &d); + sd_wr_free(&d); +} + +/* Writes an announcement to the machine known_hosts (5.6, TM-R27). The file is checked first (owner and DACL, on its + handle); a refused file is not changed. Runs on the control thread or on the pipe thread (hostkey.announced.accept), + never with g.lock held. */ +static sd_err svc_machine_apply(const sd_mount_announcement *a, int add_only) +{ + sd_kh_store st; + svc_folder_reason why = SVC_FOLDER_OK; + DWORD e; + if (g.kh[0] == 0) + return SD_E_FAILURE; + e = svc_store_check(g.kh, g.owner_trusted, NULL, &why); + if (e != 0 || why != SVC_FOLDER_OK) { + SD_LOGW("svc", "the machine known_hosts is refused (win32 %u, reason %d): not changed", (unsigned)e, (int)why); + return SD_E_ACCESS_DENIED; + } + memset(&st, 0, sizeof(st)); + st.kind = SD_KH_STORE_MACHINE; + st.path = g.kh; + st.writable = 1; + return sd_mount_apply_hostkeys_to(g.be, &st, 1, SD_KH_STORE_MACHINE, a, add_only); +} + +/* ---- hostkey add: the connect-only hostkey mount (M5, 03 §2.6 rule 2, §6.1) ---- */ + +static int hk_type_ok(const char *t) +{ + size_t i, n = strlen(t); + if (n == 0 || n >= 64) return 0; + for (i = 0; i < n; i++) + if (!((t[i] >= 'a' && t[i] <= 'z') || (t[i] >= '0' && t[i] <= '9') || t[i] == '-' || t[i] == '@' || t[i] == '.')) + return 0; + return 1; +} + +/* The IPC code and the English detail of a failed hostkey connection (the CLI maps the code to its text). */ +static void hk_error_text(sd_err e, const char *mount_code, const char **code, char *detail, size_t cap) +{ + *code = "connect_failed"; + strcpy_s(detail, cap, "cannot get the host keys of the server"); + if (e == SD_E_UNSUPPORTED) { + *code = "not_implemented"; + strcpy_s(detail, cap, "hostkey add is not available on this backend"); + } else if (e == SD_E_HOSTKEY_MISMATCH) { + *code = "host_key_mismatch"; + strcpy_s(detail, cap, "the server's host key does not match the stored one"); + } else if (e == SD_E_HOSTKEY_TYPE_UNAVAILABLE) { + *code = "host_key_type_unavailable"; + strcpy_s(detail, cap, "the server offers none of the key types stored for it"); + } else if (e == SD_E_HOSTKEY_REJECTED) { + *code = "host_key_rejected"; + strcpy_s(detail, cap, "the host key was not accepted"); + } else if (e == SD_E_INVALID_PARAM) { + *code = "usage"; + strcpy_s(detail, cap, "invalid host"); + } else if (e == SD_E_NO_MEMORY) { + *code = NULL; + strcpy_s(detail, cap, "out of memory"); + } else if (mount_code != NULL) { + *code = mount_code; + } +} + +/* Sends the deferred reply of a hostkey.add once (control thread). */ +static void svc_hk_send(svc_hk *hk, sd_err e, const char *code, const char *detail, const char *json, size_t len) +{ + sd_err se; + if (hk->replied) + return; + hk->replied = 1; + if (g.srv == NULL) + return; + se = sd_ipc_reply_deferred(g.srv, hk->defer_cid, hk->defer_rid, e, code, detail, json, len); + if (se != SD_OK && se != SD_E_NOT_FOUND) + SD_LOGW("svc", "hostkey add reply for %Hs not sent (%s)", hk->lookup, sd_err_name(se)); +} + +/* The keys of a finished hostkey connection: the requested type only, fingerprints recomputed from the blobs. With + expect the one key with that fingerprint goes to the machine store with no question (scripts); otherwise the keys are + kept as an announcement (hostkey.announced.accept {id, add_only: true} stores them after the administrator's yes). + Writes the reply members into jw (the caller opened and closes the object); on failure sets *code and detail. */ +static sd_err svc_hk_complete(sd_json_writer *jw, const svc_hk *hk, sd_mount_hk_fetch *f, const char **code, char *detail, + size_t cap) +{ + uint32_t i, n = 0; + int found = -1; + const char *type = hk->type[0] ? hk->type : NULL; + const char *expect = hk->expect[0] ? hk->expect : NULL; + int verified = f->verified != 0; + for (i = 0; i < f->count; i++) { + if (type != NULL && strcmp(f->keys[i].type, type) != 0) + continue; + if (sd_hostkey_fingerprint_sha256(f->keys[i].blob, f->keys[i].len, f->keys[i].fp_sha256) != SD_OK) + continue; + if (n != i) + f->keys[n] = f->keys[i]; + n++; + } + f->count = n; + sd_jw_key(jw, "host"); sd_jw_cstr(jw, hk->host); + sd_jw_key(jw, "lookup_name"); sd_jw_cstr(jw, hk->lookup); + sd_jw_key(jw, "verified"); sd_jw_bool(jw, verified); + if (expect != NULL) { + sd_mount_announcement *a; + sd_err e; + for (i = 0; i < n; i++) + if (strcmp(f->keys[i].fp_sha256, expect) == 0) { found = (int)i; break; } + if (found < 0) { + *code = verified ? "host_key_mismatch" : "host_key_rejected"; + strcpy_s(detail, cap, "no key of the server has that fingerprint"); + return verified ? SD_E_HOSTKEY_MISMATCH : SD_E_HOSTKEY_REJECTED; + } + a = (sd_mount_announcement *)sd_calloc(1, sizeof(*a)); + if (a == NULL) { + *code = NULL; + strcpy_s(detail, cap, "out of memory"); + return SD_E_NO_MEMORY; + } + a->add[0] = f->keys[found]; + a->add_count = 1; + strcpy_s(a->host, sizeof(a->host), hk->host); + strcpy_s(a->lookup_name, sizeof(a->lookup_name), hk->lookup); + e = svc_machine_apply(a, 1); + sd_free(a); + if (e != SD_OK) { + *code = NULL; + strcpy_s(detail, cap, "cannot update known_hosts"); + return e; + } + SD_LOGI("svc", "hostkey add: stored one key of %Hs (%s)", hk->lookup, verified ? "verified" : "first contact"); + sd_jw_key(jw, "added"); sd_jw_int(jw, 1); + sd_jw_key(jw, "removed"); sd_jw_int(jw, 0); + write_keys_json(jw, "keys", f->keys, n); + return SD_OK; + } + write_keys_json(jw, "keys", f->keys, n); + if (n > 0) { + sd_mount_announcement *a = (sd_mount_announcement *)sd_calloc(1, sizeof(*a)); + char id[16]; + if (a == NULL) { + *code = NULL; + strcpy_s(detail, cap, "out of memory"); + return SD_E_NO_MEMORY; + } + memcpy(a->add, f->keys, n * sizeof(f->keys[0])); + a->add_count = n; + strcpy_s(a->host, sizeof(a->host), hk->host); + strcpy_s(a->lookup_name, sizeof(a->lookup_name), hk->lookup); + if (!ann_keep("", a, 0, id, sizeof(id))) { + sd_free(a); + *code = NULL; + strcpy_s(detail, cap, "out of memory"); + return SD_E_NO_MEMORY; + } + sd_free(a); + sd_jw_key(jw, "id"); sd_jw_cstr(jw, id); + SD_LOGI("svc", "hostkey add: %Hs offers %u key(s), kept for review (%s)", hk->lookup, (unsigned)n, id); + } + return SD_OK; +} + +/* Control thread: the hostkey mount ended (final); the deferred hostkey.add request is answered once. */ +static void svc_hk_reply(svc_hk *hk, sd_mount *m, sd_err why) +{ + sd_mount_hk_fetch *f = NULL; + sd_mount_failure fl; + sd_wr d; + sd_json_writer jw; + const char *code = NULL; + char detail[256]; + sd_err e = why; + int ok = 0; + detail[0] = 0; + if (why == SD_OK) { + f = (sd_mount_hk_fetch *)sd_calloc(1, sizeof(*f)); + e = f == NULL ? SD_E_NO_MEMORY : sd_mount_get_hostkeys(m, f); + /* a connection that ended before the keys were read (a stop) is a failure, not an empty answer */ + if (e == SD_OK && f->verified == 0 && f->count == 0) + e = SD_E_FAILURE; + } + if (e == SD_OK) { + sd_wr_init(&d, 16 * 1024); + sd_jw_init(&jw, &d); + sd_jw_obj_begin(&jw); + e = svc_hk_complete(&jw, hk, f, &code, detail, sizeof(detail)); + if (e == SD_OK) { + sd_jw_obj_end(&jw); + if (sd_jw_finish(&jw) == SD_OK) { + ok = 1; + } else { + e = SD_E_INTERNAL; + code = NULL; + strcpy_s(detail, sizeof(detail), "reply too large"); + } + } + if (ok) + svc_hk_send(hk, SD_OK, NULL, NULL, (const char *)d.p, d.len); + sd_wr_free(&d); + } + if (!ok) { + /* the mount's recorded failure decides the code, whatever the state's reason says (the failure may be reported + with SD_OK as its reason) */ + memset(&fl, 0, sizeof(fl)); + sd_mount_get_failure(m, &fl); + if (fl.err != SD_OK || why != SD_OK) { + if (fl.err != SD_OK) + e = fl.err; + code = NULL; + detail[0] = 0; + hk_error_text(e, fl.code, &code, detail, sizeof(detail)); + if (fl.detail[0] != 0 && code != NULL && strcmp(code, "connect_failed") == 0) + strcpy_s(detail, sizeof(detail), fl.detail); + } else if (code == NULL && detail[0] == 0) { + code = "connect_failed"; + strcpy_s(detail, sizeof(detail), "cannot get the host keys of the server"); + } + svc_hk_send(hk, e, code, detail, NULL, 0); + } + sd_free(f); +} + +static void svc_hk_on_state(void *ctx, sd_mount *m, sd_mount_state st, sd_err why) +{ + svc_hk *hk = (svc_hk *)ctx; + if (hk == NULL || hk->replied) + return; + if (st != SD_MOUNT_UNMOUNTED && st != SD_MOUNT_FAILED) + return; + if (!sd_mount_is_final(m)) + return; + svc_hk_reply(hk, m, why); +} + +/* A hostkey connection raises no routed prompt: the host-key question is taken by sd_mount itself (first contact, 5.2), + and every other prompt (a password or a passphrase the service cannot ask for, 2.6) is refused. */ +static void svc_hk_on_prompt(void *ctx, sd_mount *m, const sd_mount_prompt *p) +{ + svc_hk *hk = (svc_hk *)ctx; + if (p->kind == SD_MP_HOSTKEY) /* only a jump host's key reaches here (sd_mount takes the target's first contact) */ + SD_LOGW("svc", "hostkey add for %Hs: the key of jump host %Hs is not in the machine store: refused (add it first)", + hk ? hk->lookup : "", p->host); + else + SD_LOGW("svc", "hostkey add for %Hs asked for a %s prompt: refused (no one can answer it)", + hk ? hk->lookup : "", sd_mount_prompt_kind_name(p->kind)); + (void)sd_mount_answer(m, p->id, SD_MA_REJECT, 0, NULL); +} + +/* Control thread: starts the connect-only mount of one hostkey.add with the machine profile's service credentials. */ +static void job_hk_start(void *arg) +{ + svc_hk *hk = (svc_hk *)arg; + sd_mount_params pr; + sd_mount_callbacks cb; + sd_mount *m = NULL; + wchar_t hostw[256], namew[160]; + svc_chain *chain = NULL; /* the hops (TM-R27 key bytes inside), wiped after sd_mount_create copied them */ + sd_kh_store store; + const sd_profile *p = &hk->prof->p; + const char *why = NULL; + uint32_t methods = 0; + sd_err e = SD_OK; + + memset(&pr, 0, sizeof(pr)); + pr.struct_size = sizeof(pr); + wide_from_utf8(p->host, hostw, SD_COUNTOF(hostw)); + pr.host = hostw; + pr.port = p->port ? p->port : 22; + pr.user = p->user; + if (p->user[0] == 0) + why = "service profile without a user"; + if (p->auth_methods & SD_PROFILE_AUTH_PUBLICKEY) methods |= SD_AUTH_PUBLICKEY; + if (p->auth_methods & SD_PROFILE_AUTH_PASSWORD) methods |= SD_AUTH_PASSWORD; + if (methods == 0 && why == NULL) + why = "no non-interactive authentication method"; + pr.auth_methods = methods; + if (why == NULL) { + why = svc_chain_build(&chain, p, SD_HKU_NO); + if (why == NULL) { + pr.hops = chain->hop; + pr.hop_count = chain->count; + } + } + if (why == NULL) + why = svc_kh_read_check(); + if (why == NULL) { + /* the machine store is read here (the stored types decide what the server offers); writes are the accept's (5.6) */ + memset(&store, 0, sizeof(store)); + store.kind = SD_KH_STORE_MACHINE; + store.path = g.kh; + store.writable = 0; + pr.kh_stores = &store; + pr.kh_store_count = 1; + pr.hostkey_fetch = 1; /* connect-only: no sftp, no vfs, no drive (03 §6.1) */ + pr.hostkey_type = hk->type[0] ? hk->type : NULL; + wide_from_utf8(p->name, namew, SD_COUNTOF(namew)); + pr.profile_name = namew; + pr.backend = SD_MOUNT_BACKEND_DOKANY; + pr.global_mount = 0; + pr.letter = 0; /* a hostkey mount has no drive (params.hostkey_fetch) */ + pr.connections = 1; + pr.no_secret_cache = 1; /* the service never keeps a secret it was given (2.5) */ + pr.legacy_policy = p->legacy_algorithms; + pr.refuse_terrapin_exposed = p->refuse_terrapin_exposed; + memset(&cb, 0, sizeof(cb)); + cb.ctx = hk; + cb.post = svc_post; + cb.on_state = svc_hk_on_state; + cb.on_prompt = svc_hk_on_prompt; + cb.secret_get = svc_secret_get; + cb.on_warning = svc_on_warning; + cb.on_notice = svc_on_notice; + e = sd_mount_create(g.be, &pr, &cb, &m); + } + svc_chain_free(chain); /* sd_mount_create copied the strings and the key bytes */ + if (why != NULL) { + SD_LOGW("svc", "hostkey add for %Hs refused: %s", hk->lookup, why); + svc_hk_send(hk, SD_E_INVALID_PARAM, "usage", why, NULL, 0); + return; + } + if (e != SD_OK) { + SD_LOGW("svc", "hostkey add for %Hs: the connection cannot start (%s)", hk->lookup, sd_err_name(e)); + svc_hk_send(hk, e, "connect_failed", "cannot start the connection", NULL, 0); + return; + } + hk->m = m; + (void)sd_mount_start(m); /* the reply comes through svc_hk_on_state */ +} + +/* Control thread, from svc_tick: a request whose reply is sent. Its mount is destroyed and the record leaves the list. */ +static void svc_hk_release(svc_hk *hk) +{ + sd_mount *m = hk->m; + svc_hk **pp; + sd_mutex_lock(&g.lock); + for (pp = &g.hks; *pp; pp = &(*pp)->next) + if (*pp == hk) { *pp = hk->next; break; } + g.nhk--; + sd_mutex_unlock(&g.lock); + hk->m = NULL; + if (m) + sd_mount_destroy(m); + sd_free(hk->prof); + sd_free(hk); +} + +/* ---- control-thread jobs ---- */ + +static void job_start(void *arg) +{ + svc_job *j = (svc_job *)arg; + svc_mount *r; + sd_mount_params pr; + sd_mount_callbacks cb; + sd_mount *m = NULL; + wchar_t hostw[256], labelw[160], namew[160], fsw[32]; + wchar_t wsddl_buf[1024]; + char sddl[1024]; + PSECURITY_DESCRIPTOR sd_bytes = NULL; + ULONG sd_len = 0; + svc_chain *chain = NULL; /* the hops; key bytes (TM-R27) are wiped after sd_mount_create copied them */ + PSID acc_sids[SVC_ACL_MAX], wr_sids[SVC_ACL_MAX]; /* M5: per-request lists (LocalFree after sd_mount_create) */ + uint32_t n_acc_s = 0, n_wr_s = 0, k; + sd_kh_store store; + sd_err e; + const char *why = NULL; + uint32_t methods = 0; + + sd_mutex_lock(&g.lock); + r = find_by_id_locked(j->id); + if (r == NULL || r->stop_req) { + if (r) r->final = 1; + sd_mutex_unlock(&g.lock); + sd_free(j); + return; + } + sd_mutex_unlock(&g.lock); + /* the record's profile and letter never change, and only this thread frees the record */ + { + const sd_profile *p = &r->prof->p; + memset(&pr, 0, sizeof(pr)); + pr.struct_size = sizeof(pr); + wide_from_utf8(p->host, hostw, SD_COUNTOF(hostw)); + pr.host = hostw; + pr.port = p->port ? p->port : 22; + pr.user = p->user; + if (p->user[0] == 0) why = "service profile without a user"; + if (p->auth_methods & SD_PROFILE_AUTH_PUBLICKEY) methods |= SD_AUTH_PUBLICKEY; + if (p->auth_methods & SD_PROFILE_AUTH_PASSWORD) methods |= SD_AUTH_PASSWORD; + if (methods == 0) why = "no non-interactive authentication method"; + pr.auth_methods = methods; + if (why == NULL) { + /* 2.6 rule 1, TM-R27: the key bytes go to sd_mount as key blobs, so the mount never opens a path again */ + why = svc_chain_build(&chain, p, SD_HKU_ASK); + if (why == NULL) { + pr.hops = chain->hop; + pr.hop_count = chain->count; + } + } + pr.remote_path = p->remote_path; + pr.letter = r->letter; + pr.read_only = p->read_only; + pr.symlinks = p->symlinks; + { + /* M4 (03 §3.4, §3.5): the effective write mode, [defaults] write_mode for an unset key (see the agent) */ + sd_settings st; + DWORD fv = 0; + int wm = p->write_mode; + int force = sd_policy_get_dword(SD_POLICY_FORCEWRITETHROUGH, &fv) && fv != 0; + sd_settings_defaults(&st); + (void)sd_settings_load(&st); + if (why == NULL && sd_profile_effective_write_mode(p, &st, force, &wm) != SD_OK) + why = "write mode behind is not allowed by policy"; + pr.write_mode = wm; + } + wide_from_utf8(p->label[0] ? p->label : p->name, labelw, SD_COUNTOF(labelw)); + pr.volume_label = labelw; + wide_from_utf8(p->name, namew, SD_COUNTOF(namew)); + pr.profile_name = namew; + pr.backend = SD_MOUNT_BACKEND_DOKANY; /* global drives are Dokany in this build (1.3, D11) */ + pr.global_mount = 1; + pr.legacy_policy = p->legacy_algorithms; + pr.refuse_terrapin_exposed = p->refuse_terrapin_exposed; + pr.compression = p->compression; + pr.connections = 1; + pr.no_secret_cache = p->cache_secrets ? 0 : 1; + pr.offline_wait_ms = p->offline_wait_ms; + pr.reconnect_grace_ms = p->reconnect_grace_ms; + pr.channels = p->channels; + pr.tuning.attr_ttl_ms = p->attr_ttl_ms; + pr.tuning.dir_ttl_ms = p->dir_ttl_ms; + pr.tuning.neg_ttl_ms = p->neg_ttl_ms; + pr.tuning.open_revalidate_ms = p->open_revalidate_ms; + pr.tuning.max_readahead = p->max_readahead; + pr.tuning.umask = p->umask; + pr.tuning.hide_dotfiles = p->hide_dotfiles; + pr.tuning.map_readonly = p->map_readonly; + pr.tuning.show_special = p->show_special; + pr.tuning.case_sensitive = p->case_sensitive; + pr.tuning.fsync_on_flush = p->fsync_on_flush; + pr.tuning.remote_case = p->remote_case; + pr.tuning.shell_active_show = sd_profile_block_shell_active(p) ? 0 : 1; /* D41 */ + pr.tuning.identity_probe_exec = p->identity_probe_exec; + wide_from_utf8(p->fs_name, fsw, SD_COUNTOF(fsw)); + wcsncpy_s(pr.tuning.fs_name, SD_COUNTOF(pr.tuning.fs_name), fsw, _TRUNCATE); + /* known_hosts: the machine store only, read-only for the service (5.1, 5.6); checked like a write before it is read */ + if (why == NULL) + why = svc_kh_read_check(); + memset(&store, 0, sizeof(store)); + store.kind = SD_KH_STORE_MACHINE; + store.path = g.kh; + store.writable = 0; + pr.kh_stores = &store; + pr.kh_store_count = 1; + /* the volume security descriptor carries service_access and service_access_write (D35, 02 §6.4) */ + if (why == NULL && svc_volume_sddl(&r->prof->acl, sddl, sizeof(sddl)) != SD_OK) + why = "service_access does not encode"; + if (why == NULL) { + wide_from_utf8(sddl, wsddl_buf, SD_COUNTOF(wsddl_buf)); + if (!ConvertStringSecurityDescriptorToSecurityDescriptorW(wsddl_buf, SDDL_REVISION_1, &sd_bytes, &sd_len)) + why = "service_access does not encode"; + } + if (why == NULL) { + pr.volume_sd = sd_bytes; + pr.volume_sd_len = sd_len; + } + /* the same lists as SIDs for the per-request check in sd_vfs (M5, 02 6.7, 7.7); sd_mount copies them */ + for (k = 0; why == NULL && k < r->prof->acl.n_access; k++) { + acc_sids[n_acc_s] = sid_of(r->prof->acl.access[k]); + if (acc_sids[n_acc_s] == NULL) + why = "service_access does not resolve"; + else + n_acc_s++; + } + for (k = 0; why == NULL && k < r->prof->acl.n_write; k++) { + wr_sids[n_wr_s] = sid_of(r->prof->acl.write[k]); + if (wr_sids[n_wr_s] == NULL) + why = "service_access_write does not resolve"; + else + n_wr_s++; + } + pr.access_sids = acc_sids; + pr.n_access = n_acc_s; + pr.write_sids = wr_sids; + pr.n_write = n_wr_s; + } + if (why != NULL) { + for (k = 0; k < n_acc_s; k++) + LocalFree(acc_sids[k]); + for (k = 0; k < n_wr_s; k++) + LocalFree(wr_sids[k]); + sd_mutex_lock(&g.lock); + r->final = 1; + r->st = SD_MOUNT_FAILED; + strcpy_s(r->err_code, sizeof(r->err_code), "usage"); + strcpy_s(r->err_detail, sizeof(r->err_detail), why); + sd_mutex_unlock(&g.lock); + SD_LOGW("svc", "mount %s refused: %s", j->id, why); + svc_chain_free(chain); + announce_state(j->id, r->letter, "failed"); + sd_free(j); + return; + } + memset(&cb, 0, sizeof(cb)); + cb.post = svc_post; + cb.on_state = svc_on_state; + cb.on_prompt = svc_on_prompt; + cb.on_prompt_closed = svc_on_prompt_closed; + cb.on_write_failed = svc_on_write_failed; + cb.on_notice = svc_on_notice; + cb.on_audit = svc_on_audit; + cb.secret_get = svc_secret_get; + cb.on_warning = svc_on_warning; + cb.on_hostkeys_announced = svc_on_hostkeys; + e = sd_mount_create(g.be, &pr, &cb, &m); + svc_chain_free(chain); /* sd_mount_create copied the strings and the key bytes */ + if (sd_bytes) LocalFree(sd_bytes); + for (k = 0; k < n_acc_s; k++) + LocalFree(acc_sids[k]); + for (k = 0; k < n_wr_s; k++) + LocalFree(wr_sids[k]); + if (e != SD_OK) { + sd_mutex_lock(&g.lock); + r->final = 1; + r->st = SD_MOUNT_FAILED; + strcpy_s(r->err_code, sizeof(r->err_code), sd_err_name(e)); + strcpy_s(r->err_detail, sizeof(r->err_detail), "mount create failed"); + sd_mutex_unlock(&g.lock); + announce_state(j->id, r->letter, "failed"); + sd_free(j); + return; + } + sd_mutex_lock(&g.lock); + r->m = m; + r->started = 1; + sd_mutex_unlock(&g.lock); + sd_free(j); + (void)sd_mount_start(m); /* completion comes through svc_on_state */ +} + +static void job_stop(void *arg) +{ + svc_job *j = (svc_job *)arg; + svc_mount *r; + sd_mount *m = NULL; + sd_mutex_lock(&g.lock); + r = find_by_id_locked(j->id); + if (r) { + r->stop_req = 1; + m = r->m; + if (m == NULL) + r->final = 1; /* never created: the tick removes it */ + } + sd_mutex_unlock(&g.lock); + /* a failed mount that is already torn down needs no further stop; the tick removes its record */ + if (m && !sd_mount_is_final(m)) + (void)sd_mount_stop_ex(m, j->force); + sd_free(j); +} + +/* Destroys a mount whose teardown is complete and removes its record (control thread). */ +static void reap_mount(svc_mount *r) +{ + sd_mount *m; + uint32_t i; + int reaped_prompts = 0; + sd_mutex_lock(&g.reap); + sd_mutex_lock(&g.lock); + m = r->m; + r->m = NULL; + for (i = 0; i < SVC_MAX_PROMPTS; i++) { + if (g.prompts[i].used && g.prompts[i].mount == r) { + g.prompts[i].used = 0; + reaped_prompts = 1; + } + } + sd_mutex_unlock(&g.lock); + if (m) + sd_mount_destroy(m); + if (reaped_prompts) + svc_broadcast_attention(); + sd_mutex_unlock(&g.reap); + sd_mutex_lock(&g.lock); + for (i = 0; i < SVC_MAX_MOUNTS; i++) + if (g.mounts[i] == r) { + g.mounts[i] = NULL; + break; + } + sd_mutex_unlock(&g.lock); + SD_LOGI("svc", "mount %s removed", r->id); + sd_free(r->prof); + sd_free(r); +} + +/* Control-thread timer: reaps finished mounts, expires open prompts (2.5). */ +static void svc_tick(void) +{ + svc_mount *reap[SVC_MAX_MOUNTS]; + uint32_t n = 0, i, nexp = 0; + struct { char mid[16]; sd_mount *m; uint32_t mprompt; } expired[SVC_MAX_PROMPTS]; + uint64_t now = svc_now(); + memset(expired, 0, sizeof(expired)); + /* only this thread creates, destroys and removes records, so they stay valid after the lock is released; sd_mount + is queried outside g.lock (lock order: sd_mount never waits on g.lock while we hold it) */ + sd_mutex_lock(&g.lock); + /* a failed mount keeps its record and letter until the user unmounts it (1.11): reap only after a stop */ + for (i = 0; i < SVC_MAX_MOUNTS; i++) { + if (g.mounts[i] && g.mounts[i]->final && g.mounts[i]->stop_req) + reap[n++] = g.mounts[i]; + } + for (i = 0; i < SVC_MAX_PROMPTS; i++) { + svc_prompt *s = &g.prompts[i]; + if (s->used && now >= s->deadline && nexp < SVC_MAX_PROMPTS) { + s->used = 0; + strcpy_s(expired[nexp].mid, sizeof(expired[nexp].mid), s->mount_id); + expired[nexp].m = s->mount ? s->mount->m : NULL; + expired[nexp].mprompt = s->mprompt; + nexp++; + } + } + sd_mutex_unlock(&g.lock); + for (i = 0; i < nexp; i++) { + if (expired[i].m) + (void)sd_mount_answer(expired[i].m, expired[i].mprompt, SD_MA_REJECT, 0, NULL); + SD_LOGW("svc", "a service prompt expired without an answer (%s)", expired[i].mid); + } + if (nexp > 0) + svc_broadcast_attention(); + for (i = 0; i < n; i++) { + svc_mount *r = reap[i]; + if (r->m == NULL || sd_mount_is_final(r->m)) + reap_mount(r); + } + /* hostkey requests whose reply went out: their mounts are final, so they are destroyed here (control thread) */ + { + svc_hk *done[SVC_MAX_HK]; + svc_hk *h; + uint32_t nd = 0; + sd_mutex_lock(&g.lock); + for (h = g.hks; h != NULL && nd < SVC_MAX_HK; h = h->next) + if (h->replied) + done[nd++] = h; + sd_mutex_unlock(&g.lock); + for (i = 0; i < nd; i++) + svc_hk_release(done[i]); + } +} + +static void svc_shutdown_all(void) +{ + uint32_t i, waited = 0; + for (i = 0; i < SVC_MAX_MOUNTS; i++) { + svc_mount *r; + svc_job *j; + sd_mutex_lock(&g.lock); + r = g.mounts[i]; + j = NULL; + if (r && !r->stop_req) { + j = (svc_job *)sd_calloc(1, sizeof(*j)); + if (j) { + strcpy_s(j->id, sizeof(j->id), r->id); + j->force = 1; + } + } + sd_mutex_unlock(&g.lock); + if (j) + job_stop(j); + } + /* hostkey connections still open are stopped; their replies go out as they end (svc_hk_on_state) */ + { + sd_mount *hm[SVC_MAX_HK]; + svc_hk *h; + uint32_t nh = 0; + sd_mutex_lock(&g.lock); + for (h = g.hks; h != NULL && nh < SVC_MAX_HK; h = h->next) + if (h->m != NULL && !h->replied) + hm[nh++] = h->m; + sd_mutex_unlock(&g.lock); + for (i = 0; i < nh; i++) + (void)sd_mount_stop_ex(hm[i], 1); + } + /* run the teardown: the control loop is gone, so pump the queue and the tick here */ + while (waited < SVC_SHUTDOWN_MS) { + int pending = 0; + svc_drain(); + svc_tick(); + sd_mutex_lock(&g.lock); + for (i = 0; i < SVC_MAX_MOUNTS; i++) + if (g.mounts[i]) pending = 1; + if (g.nhk > 0) + pending = 1; + sd_mutex_unlock(&g.lock); + if (!pending) + break; + Sleep(50); + waited += 50; + } + if (waited >= SVC_SHUTDOWN_MS) + SD_LOGE("svc", "shutdown timed out with mounts still tearing down"); +} + +static void svc_loop(void) +{ + HANDLE hs[2]; + hs[0] = g.stop.h; + hs[1] = g.wake.h; + for (;;) { + svc_drain(); + svc_tick(); + if (sd_event_wait(&g.stop, 0) == SD_OK) + break; + WaitForMultipleObjects(2, hs, FALSE, 1000); + } + svc_drain(); + svc_shutdown_all(); +} + +/* ---- pipe methods ---- */ + +static void write_summary(sd_json_writer *jw, svc_mount *r) +{ + char tgt[4], id[16]; + const char *state = sd_mount_state_name(r->st); + target_of(r->letter, tgt); + strcpy_s(id, sizeof(id), r->id); + sd_jw_key(jw, "mount_id"); sd_jw_cstr(jw, id); + sd_jw_key(jw, "target"); sd_jw_cstr(jw, tgt); + sd_jw_key(jw, "state"); sd_jw_cstr(jw, state); + sd_jw_key(jw, "label"); sd_jw_cstr(jw, r->prof->p.label[0] ? r->prof->p.label : r->prof->p.name); +} + +static void write_details(sd_json_writer *jw, svc_mount *r) +{ + sd_jw_key(jw, "profile"); sd_jw_cstr(jw, r->prof->p.id); + sd_jw_key(jw, "profile_name"); sd_jw_cstr(jw, r->prof->p.name); + sd_jw_key(jw, "host"); sd_jw_cstr(jw, r->prof->p.host); + sd_jw_key(jw, "port"); sd_jw_int(jw, r->prof->p.port ? r->prof->p.port : 22); + sd_jw_key(jw, "user"); sd_jw_cstr(jw, r->prof->p.user); + sd_jw_key(jw, "remote_path"); sd_jw_cstr(jw, r->prof->p.remote_path); + sd_jw_key(jw, "backend"); sd_jw_cstr(jw, "dokany"); + sd_jw_key(jw, "global"); sd_jw_bool(jw, 1); + if (r->final && r->err_code[0]) { + sd_jw_key(jw, "last_error"); + sd_jw_obj_begin(jw); + sd_jw_key(jw, "code"); sd_jw_cstr(jw, r->err_code); + sd_jw_key(jw, "detail"); sd_jw_cstr(jw, r->err_detail); + sd_jw_obj_end(jw); + } +} + +static sd_err handle_list(sd_ipc_client *c, sd_json_writer *res) +{ + uint32_t i; + sd_jw_key(res, "mounts"); + sd_jw_arr_begin(res); + sd_mutex_lock(&g.lock); + for (i = 0; i < SVC_MAX_MOUNTS; i++) { + svc_mount *r = g.mounts[i]; + svc_member mb; + svc_rights rt; + if (r == NULL) + continue; + member_of(c, &r->prof->acl, &mb); + svc_decide(&mb, &rt); + if (!rt.see_state) + continue; + sd_jw_obj_begin(res); + write_summary(res, r); + if (rt.see_details) + write_details(res, r); + sd_jw_obj_end(res); + } + sd_jw_arr_end(res); + /* every other global mount shows its letter only (2.2, TM-R25) */ + sd_jw_key(res, "reserved_letters"); + sd_jw_arr_begin(res); + for (i = 0; i < SVC_MAX_MOUNTS; i++) { + svc_mount *r = g.mounts[i]; + svc_member mb; + svc_rights rt; + char tgt[4]; + if (r == NULL) + continue; + member_of(c, &r->prof->acl, &mb); + svc_decide(&mb, &rt); + if (rt.see_state) + continue; + target_of(r->letter, tgt); + sd_jw_cstr(res, tgt); + } + sd_jw_arr_end(res); + sd_mutex_unlock(&g.lock); + return SD_OK; +} + +static sd_err handle_status(sd_ipc_client *c, const sd_json *params, sd_json_writer *res, sd_ipc_error *err) +{ + svc_mount *r; + svc_member mb; + svc_rights rt; + sd_mutex_lock(&g.lock); + r = find_mount_locked(params); + if (r == NULL) { + sd_mutex_unlock(&g.lock); + return fail(err, SD_E_NOT_FOUND, "not_mounted", "no such global mount"); + } + member_of(c, &r->prof->acl, &mb); + svc_decide(&mb, &rt); + if (!rt.see_state) { + sd_mutex_unlock(&g.lock); + return fail(err, SD_E_ACCESS_DENIED, "access_denied", "not a user of this drive"); + } + write_summary(res, r); + if (rt.see_details) + write_details(res, r); + sd_mutex_unlock(&g.lock); + return SD_OK; +} + +static sd_err handle_mount(sd_ipc_client *c, const sd_json *params, sd_json_writer *res, sd_ipc_error *err) +{ + const char *key = sd_json_get_str(params, "profile"); + const char *tgt = sd_json_get_str(params, "target"); + svc_prof *src; + svc_prof *copy = NULL; + svc_mount *r = NULL; + sd_err e; + wchar_t letter = 0; + svc_job *job; + uint32_t i, slot = SVC_MAX_MOUNTS; + const char *bad = NULL, *why = NULL; + char detail[200]; + e = need_admin(c, err); + if (e != SD_OK) + return e; + if (key == NULL) + return fail(err, SD_E_INVALID_PARAM, "usage", "mount needs a profile"); + sd_mutex_lock(&g.lock); + src = find_profile_locked(key); + if (src == NULL) { + sd_mutex_unlock(&g.lock); + return fail(err, SD_E_NOT_FOUND, "profile_not_found", "no machine profile of scope service with that id or name"); + } + if (svc_validate_profile(&src->p, &bad, &why) != SD_OK) { + sd_mutex_unlock(&g.lock); + _snprintf_s(detail, sizeof(detail), _TRUNCATE, "%s: %s", bad ? bad : "profile", why ? why : "refused"); + return fail(err, SD_E_INVALID_PARAM, "usage", detail); + } + for (i = 0; i < SVC_MAX_MOUNTS; i++) + if (g.mounts[i] && strcmp(g.mounts[i]->prof->p.id, src->p.id) == 0) { + sd_mutex_unlock(&g.lock); + return fail(err, SD_E_EXISTS, "already_mounted", "this profile is already mounted"); + } + copy = (svc_prof *)sd_alloc(sizeof(*copy)); + if (copy == NULL) { + sd_mutex_unlock(&g.lock); + return fail(err, SD_E_NO_MEMORY, NULL, "out of memory"); + } + memcpy(copy, src, sizeof(*copy)); + /* the requested target wins over the profile's; "auto" (or nothing) picks a free letter (3.4.1 mount.target) */ + { + const char *want = (tgt && tgt[0]) ? tgt : src->p.target; + if (want[0] && _stricmp(want, "auto") != 0) { + wchar_t wt[8]; + wide_from_utf8(want, wt, SD_COUNTOF(wt)); + letter = sd_parse_drive_letter(wt); + if (letter == 0) { + sd_mutex_unlock(&g.lock); + sd_free(copy); + return fail(err, SD_E_INVALID_PARAM, "invalid_target", "the target is not a drive letter"); + } + } + } + if (letter == 0) + letter = pick_letter_locked(); + if (letter == 0 || letter_taken_locked(letter) || (GetLogicalDrives() & (1ul << (letter - L'A')))) { + sd_mutex_unlock(&g.lock); + sd_free(copy); + return fail(err, SD_E_EXISTS, "target_in_use", "no free drive letter for this mount"); + } + for (i = 0; i < SVC_MAX_MOUNTS; i++) + if (g.mounts[i] == NULL) { + slot = i; + break; + } + if (slot == SVC_MAX_MOUNTS) { + sd_mutex_unlock(&g.lock); + sd_free(copy); + return fail(err, SD_E_BUSY, "busy", "too many global mounts"); + } + r = (svc_mount *)sd_calloc(1, sizeof(*r)); + if (r == NULL) { + sd_mutex_unlock(&g.lock); + sd_free(copy); + return fail(err, SD_E_NO_MEMORY, NULL, "out of memory"); + } + g.next_mount++; + _snprintf_s(r->id, sizeof(r->id), _TRUNCATE, "g-%u", (unsigned)g.next_mount); + r->letter = letter; + r->prof = copy; + r->st = SD_MOUNT_CONNECTING; + /* the job is allocated before the record is published: no record without a job to start it */ + job = (svc_job *)sd_calloc(1, sizeof(*job)); + if (job == NULL) { + sd_mutex_unlock(&g.lock); + sd_free(r->prof); + sd_free(r); + return fail(err, SD_E_NO_MEMORY, NULL, "out of memory"); + } + strcpy_s(job->id, sizeof(job->id), r->id); + g.mounts[slot] = r; + sd_mutex_unlock(&g.lock); + SD_LOGI("svc", "mount %s queued for %c:", r->id, (char)letter); + svc_enqueue(job_start, job); + sd_jw_key(res, "mount_id"); sd_jw_cstr(res, r->id); + { + char tgt2[4]; + target_of(letter, tgt2); + sd_jw_key(res, "target"); sd_jw_cstr(res, tgt2); + } + sd_jw_key(res, "state"); sd_jw_cstr(res, "connecting"); + return SD_OK; +} + +static sd_err handle_unmount(sd_ipc_client *c, const sd_json *params, sd_json_writer *res, sd_ipc_error *err) +{ + svc_mount *r; + svc_job *job; + int force = 0; + char id[16]; + sd_err e = need_admin(c, err); + if (e != SD_OK) + return e; + sd_mutex_lock(&g.lock); + r = find_mount_locked(params); + if (r == NULL) { + sd_mutex_unlock(&g.lock); + return fail(err, SD_E_NOT_FOUND, "not_mounted", "no such global mount"); + } + strcpy_s(id, sizeof(id), r->id); + (void)sd_json_get_bool(params, "force", &force); + if (r->stop_req) { + sd_mutex_unlock(&g.lock); + } else { + r->stop_req = 1; + sd_mutex_unlock(&g.lock); + job = (svc_job *)sd_calloc(1, sizeof(*job)); + if (job == NULL) + return fail(err, SD_E_NO_MEMORY, NULL, "out of memory"); + strcpy_s(job->id, sizeof(job->id), id); + job->force = force; + svc_enqueue(job_stop, job); + } + sd_jw_key(res, "mount_id"); sd_jw_cstr(res, id); + sd_jw_key(res, "state"); sd_jw_cstr(res, "unmounting"); + return SD_OK; +} + +static sd_err handle_selftest(sd_json_writer *res) +{ + int healthy = 1; + uint32_t n; + sd_mutex_lock(&g.lock); + n = g.nprofs; + sd_mutex_unlock(&g.lock); + if (g.idle) healthy = 0; + sd_jw_key(res, "healthy"); sd_jw_bool(res, healthy); + sd_jw_key(res, "checks"); + sd_jw_arr_begin(res); + sd_jw_obj_begin(res); + sd_jw_key(res, "name"); sd_jw_cstr(res, "pipe"); + sd_jw_key(res, "result"); sd_jw_cstr(res, "pass"); + sd_jw_obj_end(res); + sd_jw_obj_begin(res); + sd_jw_key(res, "name"); sd_jw_cstr(res, "layout"); + sd_jw_key(res, "result"); sd_jw_cstr(res, g.idle ? "fail" : "pass"); + if (g.idle) { sd_jw_key(res, "detail"); sd_jw_cstr(res, "machine folder refused (owner, reparse point or rights)"); } + sd_jw_obj_end(res); + sd_jw_obj_begin(res); + sd_jw_key(res, "name"); sd_jw_cstr(res, "profiles"); + sd_jw_key(res, "result"); sd_jw_cstr(res, "pass"); + { + char d[64]; + _snprintf_s(d, sizeof(d), _TRUNCATE, "%u loaded", (unsigned)n); + sd_jw_key(res, "detail"); sd_jw_cstr(res, d); + } + sd_jw_obj_end(res); + sd_jw_arr_end(res); + return SD_OK; +} + +/* secret.set / secret.delete with kind "proxy" (4.5, 3.8, 2.6 rule 1): the password of a proxy leg of the service. proxy + is a spec without a password (sd_proxy_spec_parse refuses user:pass@), http://user@host:port or + socks5://user@host:port; the target is the one svc_proxy_secret_present reads for that leg. The value is never + echoed back, and the reply has no secret. */ +static sd_err handle_proxy_secret(const char *spec, const char *value, int set, sd_json_writer *res, sd_ipc_error *err) +{ + sd_proxy_spec x; + char target[SD_SECRET_MAX_TARGET]; + sd_err e; + if (spec == NULL || (set && (value == NULL || value[0] == 0))) + return fail(err, SD_E_INVALID_PARAM, "usage", "secret needs proxy and a non-empty value"); + if (sd_proxy_spec_parse(spec, &x) != SD_OK || (x.kind != SD_PROFILE_PROXY_HTTP && x.kind != SD_PROFILE_PROXY_SOCKS5) || + x.user[0] == 0) + return fail(err, SD_E_INVALID_PARAM, "usage", "proxy needs http:// or socks5:// with a user and no password"); + e = sd_secret_target_proxy(x.kind == SD_PROFILE_PROXY_SOCKS5 ? "socks5" : "http", x.user, x.host, x.port, target, + sizeof(target)); + if (e != SD_OK) + return fail(err, e, NULL, "bad target"); + if (set) { + e = sd_secret_put(SD_SECRET_SERVICE, SD_SECRET_PROXY, target, NULL, value, strlen(value)); + if (e != SD_OK) + return fail(err, e, NULL, "secret store failed"); + sd_jw_key(res, "stored"); sd_jw_bool(res, 1); + } else { + e = sd_secret_delete(SD_SECRET_SERVICE, SD_SECRET_PROXY, target); + if (e != SD_OK) + return fail(err, e == SD_E_NOT_FOUND ? SD_E_NOT_FOUND : e, e == SD_E_NOT_FOUND ? "secret_not_found" : NULL, + "no such secret"); + sd_jw_key(res, "deleted"); sd_jw_bool(res, 1); + } + return SD_OK; +} + +static sd_err handle_secret(sd_ipc_client *c, const sd_json *params, sd_json_writer *res, sd_ipc_error *err, int set) +{ + const char *key = sd_json_get_str(params, "profile"); + const char *kind = sd_json_get_str(params, "kind"); + const char *value = set ? sd_json_get_str(params, "value") : ""; + svc_prof *p; + char target[SD_SECRET_MAX_TARGET]; + wchar_t comment[160]; + sd_err e = need_admin(c, err); + if (e != SD_OK) + return e; + if (kind != NULL && strcmp(kind, "proxy") == 0) + return handle_proxy_secret(sd_json_get_str(params, "proxy"), value, set, res, err); + if (key == NULL || kind == NULL || value == NULL) + return fail(err, SD_E_INVALID_PARAM, "usage", "secret needs profile, kind and value"); + if (strcmp(kind, "password") != 0) + return fail(err, SD_E_UNSUPPORTED, "usage", "only password secrets are supported for service profiles in this build"); + sd_mutex_lock(&g.lock); + p = find_profile_locked(key); + if (p == NULL) { + sd_mutex_unlock(&g.lock); + return fail(err, SD_E_NOT_FOUND, "profile_not_found", "no machine profile of scope service with that id or name"); + } + e = sd_secret_target_password(p->p.user, p->p.host, p->p.port ? p->p.port : 22, target, sizeof(target)); + wide_from_utf8(p->p.name, comment, SD_COUNTOF(comment)); + sd_mutex_unlock(&g.lock); + if (e != SD_OK) + return fail(err, e, NULL, "bad target"); + if (set) { + e = sd_secret_put(SD_SECRET_SERVICE, SD_SECRET_PASSWORD, target, comment, value, strlen(value)); + if (e != SD_OK) + return fail(err, e, NULL, "secret store failed"); + sd_jw_key(res, "stored"); sd_jw_bool(res, 1); + } else { + e = sd_secret_delete(SD_SECRET_SERVICE, SD_SECRET_PASSWORD, target); + if (e != SD_OK) + return fail(err, e == SD_E_NOT_FOUND ? SD_E_NOT_FOUND : e, e == SD_E_NOT_FOUND ? "secret_not_found" : NULL, + "no such secret"); + sd_jw_key(res, "deleted"); sd_jw_bool(res, 1); + } + return SD_OK; +} + +static sd_err handle_prompt_list(sd_ipc_client *c, sd_json_writer *res, sd_ipc_error *err) +{ + uint32_t i; + sd_err e = need_admin(c, err); + uint64_t now = svc_now(); + if (e != SD_OK) + return e; + sd_jw_key(res, "prompts"); + sd_jw_arr_begin(res); + sd_mutex_lock(&g.lock); + for (i = 0; i < SVC_MAX_PROMPTS; i++) { + svc_prompt *s = &g.prompts[i]; + char pidstr[24]; + if (!s->used) continue; + _snprintf_s(pidstr, sizeof(pidstr), _TRUNCATE, "p-%u", (unsigned)s->id); + sd_jw_obj_begin(res); + sd_jw_key(res, "prompt_id"); sd_jw_cstr(res, pidstr); + sd_jw_key(res, "mount_id"); sd_jw_cstr(res, s->mount_id); + sd_jw_key(res, "profile_id"); sd_jw_cstr(res, s->profile_id); + /* the same fields as prompt.request (2.5), so an administrator UI that connects late can show the dialog */ + sd_jw_key(res, "kind"); sd_jw_cstr(res, sd_mount_prompt_kind_name((sd_mount_prompt_kind)s->kind)); + sd_jw_key(res, "host"); sd_jw_cstr(res, s->host); + sd_jw_key(res, "port"); sd_jw_int(res, s->port); + sd_jw_key(res, "user"); sd_jw_cstr(res, s->user); + sd_jw_key(res, "is_reconnect"); sd_jw_bool(res, s->is_reconnect); + sd_jw_key(res, "can_save"); sd_jw_bool(res, 0); + sd_jw_key(res, "queued"); sd_jw_bool(res, 0); + sd_jw_key(res, "deadline_ms"); sd_jw_int(res, s->deadline > now ? (int64_t)(s->deadline - now) : 0); + sd_jw_obj_end(res); + } + sd_mutex_unlock(&g.lock); + sd_jw_arr_end(res); + return SD_OK; +} + +static sd_err handle_prompt_answer(sd_ipc_client *c, const sd_json *params, sd_json_writer *res, sd_ipc_error *err) +{ + const char *pid = sd_json_get_str(params, "prompt_id"); + const char *action = sd_json_get_str(params, "action"); + const sd_json *responses = sd_json_get(params, "responses"); + const char *resp[SD_MOUNT_PROMPT_MAX_ITEMS]; + uint32_t n = 0, i; + sd_mount *m = NULL; + uint32_t mprompt = 0; + uint32_t slot = SVC_MAX_PROMPTS; + sd_err e = need_admin(c, err); + if (e != SD_OK) + return e; + if (pid == NULL || action == NULL) + return fail(err, SD_E_INVALID_PARAM, "usage", "prompt.answer needs prompt_id and action"); + if (responses && sd_json_type_of(responses) == SD_JSON_ARRAY) { + for (i = 0; i < sd_json_count(responses) && n < SD_MOUNT_PROMPT_MAX_ITEMS; i++) { + const char *s = sd_json_str(sd_json_at(responses, i), NULL); + resp[n++] = s ? s : ""; + } + } + { + uint32_t want = 0; + if (sscanf_s(pid, "p-%u", &want) != 1) + return fail(err, SD_E_NOT_FOUND, "not_found", "no such prompt"); + sd_mutex_lock(&g.reap); + sd_mutex_lock(&g.lock); + for (i = 0; i < SVC_MAX_PROMPTS; i++) + if (g.prompts[i].used && g.prompts[i].id == want) { + slot = i; + break; + } + if (slot == SVC_MAX_PROMPTS) { + sd_mutex_unlock(&g.lock); + sd_mutex_unlock(&g.reap); + return fail(err, SD_E_NOT_FOUND, "not_found", "no such prompt"); + } + m = g.prompts[slot].mount ? g.prompts[slot].mount->m : NULL; + mprompt = g.prompts[slot].mprompt; + g.prompts[slot].used = 0; + sd_mutex_unlock(&g.lock); + if (m) { + if (strcmp(action, "accept") == 0 || strcmp(action, "answer") == 0) + (void)sd_mount_answer(m, mprompt, SD_MA_RESPONSES, n, resp); + else + (void)sd_mount_answer(m, mprompt, SD_MA_REJECT, 0, NULL); + } + sd_mutex_unlock(&g.reap); + } + svc_broadcast_attention(); + (void)res; + return SD_OK; +} + +static sd_err handle_config_reload(sd_ipc_client *c, sd_json_writer *res, sd_ipc_error *err) +{ + uint32_t n; + sd_err e = need_admin(c, err); + if (e != SD_OK) + return e; + n = svc_load_profiles(); + sd_jw_key(res, "profiles"); + sd_jw_obj_begin(res); + sd_jw_key(res, "loaded"); sd_jw_int(res, n); + sd_jw_key(res, "errors"); sd_jw_arr_begin(res); sd_jw_arr_end(res); + sd_jw_obj_end(res); + sd_jw_key(res, "settings"); sd_jw_cstr(res, "unchanged"); + sd_jw_key(res, "restart_required"); sd_jw_arr_begin(res); sd_jw_arr_end(res); + return SD_OK; +} + +/* hostkey.add {host, port, lookup, type?, expect?, profile?} (03 §2.4, §6.1): the machine profile whose host and port + match (or the one named by profile) connects through a hostkey mount with its service credentials. The reply is + deferred until the connection ends; it is the agent's reply shape (host, lookup_name, verified, keys, id or added). */ +static sd_err handle_hk_add(sd_ipc_client *c, const sd_json *params, sd_json_writer *res, sd_ipc_error *err) +{ + const char *host = sd_json_get_str(params, "host"); + const char *lookup = sd_json_get_str(params, "lookup"); + const char *type = sd_json_get_str(params, "type"); + const char *expect = sd_json_get_str(params, "expect"); + const char *key = sd_json_get_str(params, "profile"); + const char *bad = NULL, *why = NULL; + int64_t port = 22; + svc_prof *src = NULL, *copy; + svc_hk *hk; + sd_err e = need_admin(c, err); + uint32_t i; + char detail[200]; + if (e != SD_OK) + return e; + sd_json_get_int64(params, "port", &port); + if (host == NULL || host[0] == 0 || lookup == NULL || lookup[0] == 0 || port < 1 || port > 65535) + return fail(err, SD_E_INVALID_PARAM, "usage", "host, port and lookup required"); + if (strlen(host) >= 256 || strlen(lookup) >= 300) + return fail(err, SD_E_INVALID_PARAM, "usage", "host or lookup too long"); + if (type != NULL && !hk_type_ok(type)) + return fail(err, SD_E_INVALID_PARAM, "usage", "invalid key type"); + if (expect != NULL && strlen(expect) >= 96) + return fail(err, SD_E_INVALID_PARAM, "usage", "invalid fingerprint"); + if (sd_event_wait(&g.stop, 0) == SD_OK) + return fail(err, SD_E_BUSY, "busy", "the service is stopping"); + copy = (svc_prof *)sd_alloc(sizeof(*copy)); + if (copy == NULL) + return fail(err, SD_E_NO_MEMORY, NULL, "out of memory"); + sd_mutex_lock(&g.lock); + if (g.nhk >= SVC_MAX_HK) { + sd_mutex_unlock(&g.lock); + sd_free(copy); + return fail(err, SD_E_BUSY, "busy", "too many host key requests"); + } + for (i = 0; i < g.nprofs && src == NULL; i++) { + const svc_prof *q = g.profs[i]; + uint16_t qp = q->p.port ? q->p.port : 22; + if (_stricmp(q->p.host, host) != 0 || qp != (uint16_t)port) + continue; + if (key == NULL || strcmp(q->p.id, key) == 0 || _stricmp(q->p.name, key) == 0) + src = g.profs[i]; + } + if (src == NULL) { + sd_mutex_unlock(&g.lock); + sd_free(copy); + return fail(err, SD_E_NOT_FOUND, "profile_not_found", "no machine profile of scope service uses this host and port"); + } + memcpy(copy, src, sizeof(*copy)); + sd_mutex_unlock(&g.lock); + if (svc_validate_profile(©->p, &bad, &why) != SD_OK) { + sd_free(copy); + _snprintf_s(detail, sizeof(detail), _TRUNCATE, "%s: %s", bad ? bad : "profile", why ? why : "refused"); + return fail(err, SD_E_INVALID_PARAM, "usage", detail); + } + hk = (svc_hk *)sd_calloc(1, sizeof(*hk)); + if (hk == NULL) { + sd_free(copy); + return fail(err, SD_E_NO_MEMORY, NULL, "out of memory"); + } + hk->prof = copy; + hk->port = (uint16_t)port; + strcpy_s(hk->host, sizeof(hk->host), host); + strcpy_s(hk->lookup, sizeof(hk->lookup), lookup); + if (type != NULL) + strcpy_s(hk->type, sizeof(hk->type), type); + if (expect != NULL) + strcpy_s(hk->expect, sizeof(hk->expect), expect); + /* the request is answered when its connection ends (svc_hk_reply); taken before the record is linked */ + if (!sd_ipc_defer(c, &hk->defer_rid)) { + sd_free(copy); + sd_free(hk); + return fail(err, SD_E_INTERNAL, NULL, "hostkey.add outside a request"); + } + hk->defer_cid = sd_ipc_client_id(c); + sd_mutex_lock(&g.lock); + if (g.nhk >= SVC_MAX_HK) { + sd_mutex_unlock(&g.lock); + (void)sd_ipc_reply_deferred(g.srv, hk->defer_cid, hk->defer_rid, SD_E_BUSY, "busy", "too many host key requests", + NULL, 0); + sd_free(copy); + sd_free(hk); + return SD_OK; + } + hk->next = g.hks; + g.hks = hk; + g.nhk++; + sd_mutex_unlock(&g.lock); + SD_LOGI("svc", "hostkey add for %Hs queued", lookup); + svc_enqueue(job_hk_start, hk); + (void)res; + return SD_OK; +} + +/* hostkey.announced.list (03 §2.4): the kept host keys, for an elevated administrator. */ +static sd_err handle_hk_list(sd_ipc_client *c, sd_json_writer *res, sd_ipc_error *err) +{ + svc_ann *x; + sd_err e = need_admin(c, err); + if (e != SD_OK) + return e; + sd_jw_key(res, "announcements"); + sd_jw_arr_begin(res); + sd_mutex_lock(&g.lock); + for (x = g.anns; x != NULL; x = x->next) + write_ann_obj(res, x->id, x->mount_id, &x->a, 0); + sd_mutex_unlock(&g.lock); + sd_jw_arr_end(res); + return SD_OK; +} + +/* hostkey.announced.reject {id} (5.6, 03 §2.4, elevated administrators): drops a kept announcement; no store is changed. + The CLI sends it when the administrator answers no, so a refused offer leaves the list at once. */ +static sd_err handle_hk_reject(sd_ipc_client *c, const sd_json *params, sd_json_writer *res, sd_ipc_error *err) +{ + const char *id = sd_json_get_str(params, "id"); + svc_ann **pp, *x = NULL; + sd_err e = need_admin(c, err); + if (e != SD_OK) + return e; + if (id == NULL) + return fail(err, SD_E_INVALID_PARAM, "usage", "id required"); + sd_mutex_lock(&g.lock); + for (pp = &g.anns; *pp != NULL; pp = &(*pp)->next) + if (strcmp((*pp)->id, id) == 0) { + x = *pp; + *pp = x->next; + g.nann--; + break; + } + sd_mutex_unlock(&g.lock); + if (x == NULL) + return fail(err, SD_E_NOT_FOUND, "announcement_not_found", "no such announcement"); + SD_LOGI("svc", "host keys of %Hs not added (%s rejected)", x->a.lookup_name, x->id); + sd_jw_key(res, "host"); sd_jw_cstr(res, x->a.host); + sd_jw_key(res, "rejected"); sd_jw_bool(res, 1); + sd_free(x); + return SD_OK; +} + +/* hostkey.announced.accept {id, add_only} (5.6, 03 §2.4): writes a kept announcement to the machine known_hosts. The + record leaves the list while it is written and returns to it when the write fails. */ +static sd_err handle_hk_accept(sd_ipc_client *c, const sd_json *params, sd_json_writer *res, sd_ipc_error *err) +{ + const char *id = sd_json_get_str(params, "id"); + int add_only = 0; + svc_ann **pp, *x = NULL; + sd_err e = need_admin(c, err); + if (e != SD_OK) + return e; + sd_json_get_bool(params, "add_only", &add_only); + if (id == NULL) + return fail(err, SD_E_INVALID_PARAM, "usage", "id required"); + sd_mutex_lock(&g.lock); + for (pp = &g.anns; *pp != NULL; pp = &(*pp)->next) + if (strcmp((*pp)->id, id) == 0) { + x = *pp; + *pp = x->next; + g.nann--; + break; + } + sd_mutex_unlock(&g.lock); + if (x == NULL) + return fail(err, SD_E_NOT_FOUND, "announcement_not_found", "no such announcement"); + e = svc_machine_apply(&x->a, add_only); + if (e != SD_OK) { + sd_mutex_lock(&g.lock); /* keep it for another try */ + x->next = g.anns; + g.anns = x; + g.nann++; + sd_mutex_unlock(&g.lock); + return fail(err, e, NULL, "cannot update known_hosts"); + } + SD_LOGI("svc", "machine host keys of %Hs updated (%u added, %u removed)", x->a.lookup_name, + (unsigned)x->a.add_count, add_only ? 0u : (unsigned)x->a.remove_count); + sd_jw_key(res, "host"); sd_jw_cstr(res, x->a.host); + sd_jw_key(res, "added"); sd_jw_int(res, x->a.add_count); + sd_jw_key(res, "removed"); sd_jw_int(res, add_only ? 0 : x->a.remove_count); + sd_free(x); + return SD_OK; +} + +/* ---- service.status and service.repair_data (03 §2.4, §6.1) ---- */ + +/* The SCM seam of service.status: svc_opts.scm_ops, else the real calls. A test build with no seam never reaches the + SCM (NULL: the report says "not installed"). */ +static const svc_scm_ops *svc_scm_seam(svc_scm_ops *real) +{ + if (g.scm_ops != NULL) + return g.scm_ops; +#ifdef SD_SVC_TEST_HOOKS + (void)real; + return NULL; +#else + svc_scm_real_ops(real); + return real; +#endif +} + +/* service.status: open to every client. The facts of `shelldrive service status --json` (svc_status.c) as this + service sees them, plus the machine data folder as found at start. The refused folder's path goes to elevated + administrators only. */ +static sd_err handle_service_status(sd_ipc_client *c, sd_json_writer *res, sd_ipc_error *err) +{ + svc_scm_ops real; + const svc_scm_ops *ops = svc_scm_seam(&real); + svc_install_spec spec; + svc_status_report r; + svc_pipe_report p; + svc_machine_report m; + uint32_t i, drives = 0; + memset(&spec, 0, sizeof(spec)); + memset(&r, 0, sizeof(r)); + memset(&p, 0, sizeof(p)); + memset(&m, 0, sizeof(m)); + if (ops != NULL) { + int have_spec = svc_scm_host_spec(&spec, L"") == 0; + if (svc_scm_status(ops, have_spec ? &spec : NULL, &r) != 0) + return fail(err, SD_E_FAILURE, "io_error", "the service manager query failed"); + } + p.reachable = 1; + p.prompts = (int64_t)svc_open_prompts(); + sd_mutex_lock(&g.lock); + for (i = 0; i < SVC_MAX_MOUNTS; i++) + if (g.mounts[i] != NULL) + drives++; + m.profiles = (int64_t)g.nprofs; + m.ok = g.idle ? 0 : 1; + m.why = g.refused_why; + if (!m.ok && caller_is_admin(c)) + _snwprintf_s(m.path, MAX_PATH, _TRUNCATE, L"%s", g.refused_path); + sd_mutex_unlock(&g.lock); + p.healthy = m.ok; + p.drives = (int64_t)drives; + svc_status_write_members(res, &r, &p, &m); + return SD_OK; +} + +/* service.repair_data: elevated administrators only. Runs svc_repair_data (the code of `shelldrive service + repair-data`) on %ProgramData%, then checks the layout again exactly like the start does and, when it is trusted, + leaves the idle state and loads the machine profiles. Refused (busy) while a global drive exists: the repair can + move the folder that holds its secrets. The log is closed around the move (an open file would block the rename). */ +static sd_err handle_service_repair(sd_ipc_client *c, sd_json_writer *res, sd_ipc_error *err) +{ + svc_repair_ops real_ops; + const svc_repair_ops *ops; + svc_repair_result r; + svc_refusal refusal; + wchar_t base[MAX_PATH]; + DWORD we; + uint32_t i; + int busy = 0, healthy; + sd_err e = need_admin(c, err); + if (e != SD_OK) + return e; + if (g.repair_base != NULL) { + _snwprintf_s(base, MAX_PATH, _TRUNCATE, L"%s", g.repair_base); + } else { +#ifdef SD_SVC_TEST_HOOKS + return fail(err, SD_E_FAILURE, "io_error", "no base folder for the repair (test build)"); +#else + if (SHGetFolderPathW(NULL, CSIDL_COMMON_APPDATA, NULL, 0, base) != S_OK) + return fail(err, SD_E_FAILURE, "io_error", "the common application data folder is not known"); +#endif + } + if (g.repair_ops != NULL) { + ops = g.repair_ops; + } else { + svc_repair_real_ops(&real_ops); + ops = &real_ops; + } + sd_mutex_lock(&g.lock); + if (g.repairing) + busy = 1; + for (i = 0; i < SVC_MAX_MOUNTS && !busy; i++) + if (g.mounts[i] != NULL) + busy = 1; + if (!busy) { + /* nothing is mounted: stop serving profiles while the tree is rebuilt */ + g.repairing = 1; + g.idle = 1; + svc_free_profiles_locked(); + } + sd_mutex_unlock(&g.lock); + if (busy) + return fail(err, SD_E_BUSY, "busy", "unmount the global drives before repairing the machine data folder"); + + if (g.open_log) + sd_log_close(); + memset(&r, 0, sizeof(r)); + we = svc_repair_data(ops, base, &r); + memset(&refusal, 0, sizeof(refusal)); + e = svc_ensure_layout(&refusal); + if (g.open_log) + (void)sd_log_open_file(g.logfile); + sd_mutex_lock(&g.lock); + g.idle = e == SD_OK ? 0 : 1; + g.refused_why = e == SD_OK ? SVC_FOLDER_OK : refusal.why; + if (e == SD_OK) + g.refused_path[0] = 0; + else + _snwprintf_s(g.refused_path, MAX_PATH, _TRUNCATE, L"%s", refusal.path); + healthy = g.idle ? 0 : 1; + g.repairing = 0; + sd_mutex_unlock(&g.lock); + if (healthy) + (void)svc_load_profiles(); + svc_log_info("machine data folder repair: error %u, %u folders, %s, %s", (unsigned)we, (unsigned)r.folders, + r.moved ? "moved aside" : "kept", healthy ? "trusted" : "still refused"); + + if (r.refused) + return fail(err, SD_E_ACCESS_DENIED, "repair_refused", "a link or a junction is in the machine data folder"); + if (we != 0) + return fail(err, SD_E_FAILURE, "io_error", "the machine data folder could not be repaired"); + svc_status_write_repair(res, &r); + sd_jw_key(res, "healthy"); + sd_jw_bool(res, healthy); + return SD_OK; +} + +static sd_err svc_ipc_handler(void *ctx, sd_ipc_client *client, const char *method, const sd_json *params, + sd_json_writer *res, sd_ipc_error *err) +{ + (void)ctx; + if (strcmp(method, "list") == 0) return handle_list(client, res); + if (strcmp(method, "hostkey.add") == 0) return handle_hk_add(client, params, res, err); + if (strcmp(method, "hostkey.announced.list") == 0) return handle_hk_list(client, res, err); + if (strcmp(method, "hostkey.announced.accept") == 0) return handle_hk_accept(client, params, res, err); + if (strcmp(method, "hostkey.announced.reject") == 0) return handle_hk_reject(client, params, res, err); + if (strcmp(method, "status") == 0) return handle_status(client, params, res, err); + if (strcmp(method, "mount") == 0) return handle_mount(client, params, res, err); + if (strcmp(method, "unmount") == 0) return handle_unmount(client, params, res, err); + if (strcmp(method, "selftest") == 0) return handle_selftest(res); + if (strcmp(method, "service.status") == 0) return handle_service_status(client, res, err); + if (strcmp(method, "service.repair_data") == 0) return handle_service_repair(client, res, err); + if (strcmp(method, "secret.set") == 0) return handle_secret(client, params, res, err, 1); + if (strcmp(method, "secret.delete") == 0) return handle_secret(client, params, res, err, 0); + if (strcmp(method, "prompt.list") == 0) return handle_prompt_list(client, res, err); + if (strcmp(method, "prompt.answer") == 0) return handle_prompt_answer(client, params, res, err); + if (strcmp(method, "config.reload") == 0) return handle_config_reload(client, res, err); + return fail(err, SD_E_UNSUPPORTED, "usage", "unknown method"); +} + +/* prompt, log and hostkeys are elevated-admin topics (2.2, TM-R25); the rest are open to every client. */ +static int svc_topic_allowed(void *ctx, sd_ipc_client *client, const char *topic) +{ + (void)ctx; + if (strcmp(topic, "prompt") == 0 || strcmp(topic, "log") == 0 || strcmp(topic, "hostkeys") == 0) + return caller_is_admin(client); + return 1; +} + +static int svc_ipc_is_admin(void *ctx, sd_ipc_client *client) +{ + (void)ctx; + return caller_is_admin(client); +} + +/* The class of a new connection, before hello (2.2): the token decides (the server), unless a test forces admin. */ +static int svc_admin_at_connect(void *ctx) +{ + (void)ctx; +#ifdef SD_SVC_TEST_HOOKS + return g.test_connect_admin ? 1 : 0; +#else + return 0; +#endif +} + +/* Event 3101 (10.3, TM-R25): a client refused at hello or over the rate limit. %1 is the SID (not written when the + token gave none), %2 the reason: token, per_sid, reserved or rate. At most once per SID per 10 minutes. Runs on the + pipe thread, the only writer of the throttle table. */ +static void svc_on_refused(void *ctx, sd_ipc_client *client, const char *reason) +{ + PSID sid = sd_ipc_client_sid(client); + LPWSTR text = NULL; + wchar_t why[32]; + const wchar_t *args[2]; + (void)ctx; + if (sid == NULL || !ConvertSidToStringSidW(sid, &text)) + return; + if (svc_refuse_log_due(g.refuse, SVC_REFUSE_SLOTS, text, sd_tick_ms())) { + wide_from_utf8(reason, why, SD_COUNTOF(why)); + args[0] = text; + args[1] = why; + svc_event(SVC_MSG_CLIENT_REFUSED, args, 2); + } + LocalFree(text); +} + +/* attention: a new subscriber gets the current count at once, so a UI that starts after a prompt knows about it. */ +static void svc_on_subscribe(void *ctx, sd_ipc_client *client, const char *topic) +{ + sd_wr w; + (void)ctx; + if (strcmp(topic, "attention") != 0 || g.srv == NULL) + return; + sd_wr_init(&w, 128); + if (build_attention(svc_open_prompts(), &w) == SD_OK) + (void)sd_ipc_send_to(g.srv, sd_ipc_client_id(client), (const char *)w.p, w.len); + sd_wr_free(&w); +} + +static const char *const k_service_features[] = { + "list", "status", "mount", "unmount", "selftest", "secret.set", "secret.delete", "prompt.list", + "prompt.answer", "config.reload", "prompt.routing", "hostkey.add", "hostkey.announced.list", + "hostkey.announced.accept", "hostkey.announced.reject", "service.status", "service.repair_data", NULL +}; + +/* ---- the core ---- */ + +static void svc_setup_secrets(const wchar_t *dir) +{ + sd_secret_config sc; + wchar_t userdir[MAX_PATH]; + memset(&sc, 0, sizeof(sc)); + /* the service never reads the user scope: point it at a folder that does not exist, so no user secret can be + mistaken for a service one (or the other way round) */ + _snwprintf_s(userdir, MAX_PATH, _TRUNCATE, L"%s\\no-user-scope", g.root); + sc.user_dir = userdir; + sc.service_dir = dir; + sc.force_dpapi = 1; /* service secrets are DPAPI files under SYSTEM (4.5), never Credential Manager */ + (void)sd_secret_configure(&sc); +} + +int svc_run(const svc_opts *o) +{ + sd_ipc_server_cfg cfg; + svc_refusal refusal; + sd_err e; + int rc = 0; + memset(&g, 0, sizeof(g)); + sd_mutex_init(&g.lock); + sd_mutex_init(&g.reap); + if (sd_event_init(&g.wake, 0, 0) != SD_OK || sd_event_init(&g.stop, 1, 0) != SD_OK) { + sd_mutex_destroy(&g.lock); + sd_mutex_destroy(&g.reap); + return 1; + } + g.be = (o && o->backend) ? o->backend : host_backend(); + g.event = o ? o->event : NULL; + g.event_ctx = o ? o->event_ctx : NULL; + g.owner_trusted = o ? o->owner_trusted : NULL; + g.open_log = o && o->open_log ? 1 : 0; + g.scm_ops = o ? o->scm_ops : NULL; + g.repair_ops = o ? o->repair_ops : NULL; + g.repair_base = o ? o->repair_base : NULL; + g.sysproxy = (o && o->sysproxy_ops) ? o->sysproxy_ops : &k_sysproxy_shipping; +#ifdef SD_SVC_TEST_HOOKS + g.test_all_admin = o ? o->test_all_admin : 0; + g.dacl_extra = o ? o->dacl_extra_ace : NULL; +#endif + if (svc_compute_paths(o ? o->machine_root : NULL) != SD_OK) { + rc = 1; + goto out_free_events; + } + if (o && o->open_log && sd_log_open_file(g.logfile) != SD_OK) + SD_LOGW("svc", "the service log is not open"); + memset(&refusal, 0, sizeof(refusal)); + e = svc_ensure_layout(&refusal); + if (e != SD_OK) { + g.idle = 1; + g.refused_why = refusal.why; + _snwprintf_s(g.refused_path, MAX_PATH, _TRUNCATE, L"%s", refusal.path); + SD_LOGE("svc", "the machine folder is refused (%s): the service stays idle (3.2)", sd_err_name(e)); + svc_event_untrusted(&refusal); + } + svc_setup_secrets(o && o->secret_dir ? o->secret_dir : g.service_dir); + (void)svc_load_profiles(); + svc_log_info("service starting; %u machine profiles", (unsigned)g.nprofs); + + memset(&cfg, 0, sizeof(cfg)); + cfg.struct_size = sizeof(cfg); + cfg.handler = svc_ipc_handler; + cfg.ctx = NULL; + cfg.max_instances = 32; + cfg.max_message = SD_IPC_MAX_MESSAGE; /* 2.2: admins, 1 MiB */ + cfg.max_message_nonadmin = SVC_MAX_MESSAGE_NONADMIN; + cfg.idle_timeout_ms = SVC_IDLE_MS; + cfg.max_per_sid = 4; /* TM-R25 */ + cfg.max_requests_per_sec = 10; /* TM-R25; admins are exempt in the server */ + cfg.reserved_admin_instances = SVC_RESERVED_ADMIN_INSTANCES; + cfg.hello_timeout_ms = SVC_HELLO_MS; /* 2.2: a non-admin that does not say hello in time is closed */ + cfg.admin_at_connect = svc_admin_at_connect; + cfg.is_admin = svc_ipc_is_admin; + cfg.on_refused = svc_on_refused; + cfg.features = k_service_features; + cfg.topic_allowed = svc_topic_allowed; + cfg.on_subscribe = svc_on_subscribe; + e = sd_ipc_server_start_ex(SD_IPC_SERVICE, &cfg, &g.srv); + if (e != SD_OK) { + wchar_t why[64]; + const wchar_t *args[1]; + SD_LOGE("svc", "the control pipe did not start (%s)", sd_err_name(e)); + wide_from_utf8(sd_err_name(e), why, SD_COUNTOF(why)); + args[0] = why; + svc_event(SVC_MSG_START_FAILED, args, 1); + rc = 1; + goto out_secrets; + } + g.running = 1; + if (o && o->on_running) + o->on_running(o->ctx); + svc_log_info("service running"); + { + const wchar_t *args[1]; + args[0] = SD_VERSION_STRING_W; + svc_event(SVC_MSG_STARTED, args, 1); + } + svc_loop(); + g.running = 0; + g.stopping = 1; + sd_ipc_server_stop(g.srv); + g.srv = NULL; + svc_log_info("service stopped"); + svc_event(SVC_MSG_STOPPED, NULL, 0); + +out_secrets: + (void)sd_secret_configure(NULL); + sd_mutex_lock(&g.lock); + svc_free_profiles_locked(); + sd_mutex_unlock(&g.lock); + if (o && o->open_log) + sd_log_close(); +out_free_events: + sd_event_destroy(&g.stop); + sd_event_destroy(&g.wake); + sd_mutex_destroy(&g.reap); + sd_mutex_destroy(&g.lock); + return rc; +} + +void svc_request_stop(void) +{ + if (g.stop.h != NULL) + sd_event_set(&g.stop); + if (g.wake.h != NULL) + SetEvent(g.wake.h); +} + +/* ---- console mode ---- */ + +static BOOL WINAPI console_ctrl(DWORD type) +{ + if (type == CTRL_C_EVENT || type == CTRL_BREAK_EVENT || type == CTRL_CLOSE_EVENT) { + svc_request_stop(); + return TRUE; + } + return FALSE; +} + +static void console_running(void *ctx) +{ + (void)ctx; + say(SD_MSG_SVC_DEBUG_STARTED); +} + +int svc_console_run(void) +{ + svc_opts o; + int rc; + sd_l10n_init(&sd_l10n_catalog_svc, NULL); + memset(&o, 0, sizeof(o)); + o.open_log = 1; + o.on_running = console_running; + SetConsoleCtrlHandler(console_ctrl, TRUE); + rc = svc_run(&o); + SetConsoleCtrlHandler(console_ctrl, FALSE); + say(SD_MSG_SVC_DEBUG_STOPPED); + return rc; +} + +/* ---- Service Control Manager ---- */ + +static SERVICE_STATUS_HANDLE g_scm_handle; +static SERVICE_STATUS g_scm_status; +static DWORD g_scm_checkpoint; + +static void scm_report(DWORD state, DWORD win32_exit, DWORD wait_hint) +{ + g_scm_status.dwServiceType = SERVICE_WIN32_OWN_PROCESS; + g_scm_status.dwCurrentState = state; + g_scm_status.dwControlsAccepted = state == SERVICE_RUNNING + ? (SERVICE_ACCEPT_STOP | SERVICE_ACCEPT_SHUTDOWN | SERVICE_ACCEPT_PRESHUTDOWN | SERVICE_ACCEPT_POWEREVENT | + SERVICE_ACCEPT_SESSIONCHANGE) + : 0; + g_scm_status.dwWin32ExitCode = win32_exit; + g_scm_status.dwServiceSpecificExitCode = 0; + g_scm_status.dwWaitHint = wait_hint; + g_scm_status.dwCheckPoint = (state == SERVICE_RUNNING || state == SERVICE_STOPPED) ? 0 : ++g_scm_checkpoint; + if (g_scm_handle) + SetServiceStatus(g_scm_handle, &g_scm_status); +} + +static DWORD WINAPI scm_control(DWORD ctrl, DWORD evt, LPVOID data, LPVOID ctx) +{ + (void)evt; + (void)data; + (void)ctx; + switch (ctrl) { + case SERVICE_CONTROL_STOP: + case SERVICE_CONTROL_SHUTDOWN: + case SERVICE_CONTROL_PRESHUTDOWN: + scm_report(SERVICE_STOP_PENDING, NO_ERROR, 30000); + svc_request_stop(); + return NO_ERROR; + case SERVICE_CONTROL_POWEREVENT: + SD_LOGI("svc", "power event %u", (unsigned)evt); /* no action: mounts stay (1.3) */ + return NO_ERROR; + case SERVICE_CONTROL_SESSIONCHANGE: + SD_LOGI("svc", "session change %u", (unsigned)evt); /* the admin-session list arrives with the UI (2.6) */ + return NO_ERROR; + case SERVICE_CONTROL_INTERROGATE: + return NO_ERROR; + default: + return ERROR_CALL_NOT_IMPLEMENTED; + } +} + +static void scm_running(void *ctx) +{ + (void)ctx; + scm_report(SERVICE_RUNNING, NO_ERROR, 0); +} + +static void WINAPI scm_main(DWORD argc, LPWSTR *argv) +{ + svc_opts o; + int rc; + (void)argc; + (void)argv; + g_scm_handle = RegisterServiceCtrlHandlerExW(SVC_SERVICE_NAME, scm_control, NULL); + if (g_scm_handle == NULL) + return; + memset(&g_scm_status, 0, sizeof(g_scm_status)); + scm_report(SERVICE_START_PENDING, NO_ERROR, 10000); + memset(&o, 0, sizeof(o)); + o.open_log = 1; + o.on_running = scm_running; + o.event = svc_eventlog_write; + rc = svc_run(&o); + g_scm_status.dwServiceSpecificExitCode = (DWORD)rc; + scm_report(SERVICE_STOPPED, rc ? ERROR_SERVICE_SPECIFIC_ERROR : NO_ERROR, 0); +} + +int svc_dispatch(void) +{ + SERVICE_TABLE_ENTRYW table[2]; + sd_l10n_init(&sd_l10n_catalog_svc, NULL); + memset(table, 0, sizeof(table)); + table[0].lpServiceName = (LPWSTR)SVC_SERVICE_NAME; + table[0].lpServiceProc = scm_main; + if (!StartServiceCtrlDispatcherW(table)) { + say(SD_MSG_SVC_NOT_SCM); + return 1; + } + return (int)g_scm_status.dwServiceSpecificExitCode; +} diff --git a/apps/shelldrive-host/service.h b/apps/shelldrive-host/service.h new file mode 100644 index 0000000..376941f --- /dev/null +++ b/apps/shelldrive-host/service.h @@ -0,0 +1,89 @@ +/* shelldrive-host service mode: ShellDriveSvc (03 §1.3, §2.2, §2.6; D6, D17, D35). + * + * svc_run is the one service core. It runs the same code under the Service Control Manager (svc_dispatch) and in + * the console (svc_console_run, `shelldrive-host.exe service-debug`), and it is what the in-process tests call with a + * fake mount backend (tests/unit/sd_svc). Only one core runs per process. */ +#ifndef SERVICE_H +#define SERVICE_H + +#include + +#include "sd_mount/sd_mount.h" +#include "svc_folder.h" +#include "svc_repair.h" +#include "svc_scm.h" + +#define SVC_SERVICE_NAME L"ShellDriveSvc" + +/* Event Log sink (10.3): msg_id is an id of svc_events.mc, args are its %1..%n inserts. */ +typedef void (*svc_event_fn)(void *ctx, DWORD msg_id, const wchar_t **args, size_t nargs); + +/* proxy = system of a service profile (M5, 2.6 rule 1, 3.8). read: the WinHTTP machine configuration for host:port; + kind is the SD_PROXY_* value (0 direct, 1 HTTP, 2 SOCKS5), the proxy in host_out / port_out. allow: 1 or 0 is the + answer of the seam itself (0 refuses the leg with usage); -1 = the machine policy AllowSystemProxy, read at each + leg from HKLM only (sd_policy_machine_get_dword). Shipping: allow -1 (so the policy decides) and the real + sd_ssh_proxy_machine. The machine policy AllowSystemProxySspi is read the same way when allow is -1; with 0 or 1 + SSPI towards the system proxy is never allowed. Tests pass a stub. */ +typedef sd_err (*svc_sysproxy_read_fn)(void *ctx, const wchar_t *host, uint16_t port, int *kind, wchar_t host_out[256], + uint16_t *port_out); +typedef struct svc_sysproxy_ops { + int allow; + svc_sysproxy_read_fn read; + void *ctx; +} svc_sysproxy_ops; + +typedef struct svc_opts { + const sd_mount_backend *backend; /* NULL = host_backend() (the real modules or the unavailable stub) */ + const wchar_t *machine_root; /* NULL = %ProgramData%\LibreStatic\ShellDrive (tests: a temp folder) */ + const wchar_t *secret_dir; /* NULL = \service (tests: a temp folder) */ + int open_log; /* 1 = \logs\service.log */ + void (*on_running)(void *ctx); /* called once the control pipe listens (SCM: SERVICE_RUNNING) */ + void *ctx; + svc_event_fn event; /* NULL = no Event Log writes (console mode, tests); SCM: svc_eventlog_write */ + void *event_ctx; + svc_owner_fn owner_trusted; /* NULL = the shipping owner rule (3.2): SYSTEM or Administrators; tests replace it */ + int test_all_admin; /* SD_SVC_TEST_HOOKS builds only: every client counts as an elevated admin */ + /* SD_SVC_TEST_HOOKS builds only (ignored by the shipping build): an SDDL ACE appended to every machine DACL the start + sets or checks, e.g. "(A;OICI;FA;;;OW)". A non-elevated test process is only the owner of the folders it made, and + the owner needs the rights BUILTIN\Administrators has; the same seam as svc_repair_ops.extra_ace. NULL = none. */ + const wchar_t *dacl_extra_ace; + /* Seams of service.status and service.repair_data (03 §2.4). scm_ops: the Service Control Manager queries of + svc_scm_status; NULL = the real SCM (SD_SVC_TEST_HOOKS builds never reach it: NULL there reports "not installed"). + repair_ops / repair_base: the owner rule and the base folder of svc_repair_data; NULL = the shipping rule and + %ProgramData% (SD_SVC_TEST_HOOKS builds refuse the method when repair_base is NULL, so a test never touches the real + machine folder). The pointers must stay valid for the life of svc_run. */ + const svc_scm_ops *scm_ops; + const svc_repair_ops *repair_ops; + const wchar_t *repair_base; + /* proxy = system seams of service profiles (svc_sysproxy_ops above). NULL = refused (allow 0) with the real read. */ + const svc_sysproxy_ops *sysproxy_ops; +} svc_opts; + +/* Blocks until svc_request_stop (or a stop control). Returns 0, or 1 when the core cannot start (no pipe, no folder + rights). Every mount is unmounted (forced) before it returns. */ +int svc_run(const svc_opts *o); +/* Any thread. Safe to call when no core runs. */ +void svc_request_stop(void); +/* Service entry: StartServiceCtrlDispatcherW. Returns the service exit code, or 1 when the process was not started by + the SCM (the message says so, localized in the svc domain). */ +int svc_dispatch(void); +/* Console mode for `shelldrive-host.exe service-debug`: the same core, Ctrl+C stops it. */ +int svc_console_run(void); + +#ifdef SD_SVC_TEST_HOOKS +/* SD_SVC_TEST_HOOKS builds only. forced = 0 reads the UpdateHostKeys policy from the registry (default); forced = 1 uses + value (2 = accept proven keys, 1 = ask, 0 = never) for every announcement from now on. */ +void svc_test_hk_policy(int forced, DWORD value); +/* SD_SVC_TEST_HOOKS builds only. on = 1: every new pipe connection counts as an elevated administrator at connect (the + class of the reserved-instance rule, 2.2); the token class is used again when on = 0. Tests of the reserved instances + use it for their raw instances, which an unelevated test process could not hold otherwise. */ +void svc_test_connect_admin(int on); +/* SD_SVC_TEST_HOOKS builds only. on = 1: every client, whatever its hello kind, passes the admin check of the methods + (the e2e host runs the real CLI, whose hello kind is "cli"); the restricted-token rules of the pipe are unchanged. */ +void svc_test_admin_all_clients(int on); +/* SD_SVC_TEST_HOOKS builds only. forced = 0 reads AllowSystemProxy and AllowSystemProxySspi from HKLM (only with + svc_opts.sysproxy_ops.allow = -1); forced = 1 uses allow and sspi (0 or 1) for every leg from now on. */ +void svc_test_sysproxy_policy(int forced, DWORD allow, DWORD sspi); +#endif + +#endif /* SERVICE_H */ diff --git a/apps/shelldrive-host/state_producer.c b/apps/shelldrive-host/state_producer.c new file mode 100644 index 0000000..838e667 --- /dev/null +++ b/apps/shelldrive-host/state_producer.c @@ -0,0 +1,103 @@ +/* State snapshot producer (03 §8.2.1). See state_producer.h. */ +#include +#include + +#include "state_producer.h" +#include "sd_base/sd_mem.h" +#include "sd_platform/sd_log.h" +#include "sd_state/sd_state.h" + +#define SP_LOG "state" + +static sd_state_writer *g_writer; +static sd_state_entry *g_entries; /* SD_STATE_MAX_ENTRIES, allocated at open */ + +static void utf8_into(const char *s, wchar_t *dst, uint32_t cch, int *cut) +{ + wchar_t tmp[1024]; /* room for any UTF-8 input the agent stores (paths are at most 1024 bytes) */ + tmp[0] = 0; + if (s && s[0]) { + if (MultiByteToWideChar(CP_UTF8, 0, s, -1, tmp, (int)SD_COUNTOF(tmp)) == 0) + tmp[0] = 0; + tmp[SD_COUNTOF(tmp) - 1] = 0; + } + if (sd_state_set_string(dst, cch, tmp)) + *cut = 1; +} + +sd_err host_state_open(void) +{ + sd_err e; + if (g_writer != NULL) + return SD_OK; + g_entries = (sd_state_entry *)sd_calloc(SD_STATE_MAX_ENTRIES, sizeof(sd_state_entry)); + if (g_entries == NULL) + return SD_E_NO_MEMORY; + e = sd_state_writer_open(&g_writer); + if (e != SD_OK) { + g_writer = NULL; + sd_free(g_entries); + g_entries = NULL; + return e; + } + host_state_publish(NULL, 0, 0); + return SD_OK; +} + +void host_state_publish(const host_state_src *items, uint32_t n, uint32_t flags) +{ + sd_state_publish p; + uint32_t i, cuts = 0; + sd_err e; + if (g_writer == NULL || g_entries == NULL) + return; + if (n > SD_STATE_MAX_ENTRIES) + n = SD_STATE_MAX_ENTRIES; + memset(g_entries, 0, n * sizeof(sd_state_entry)); + for (i = 0; i < n; i++) { + const host_state_src *s = &items[i]; + sd_state_entry *d = &g_entries[i]; + int cut = 0; + d->flags = (s->read_only ? SD_STATE_EF_READ_ONLY : 0u) | + (s->network_location ? SD_STATE_EF_NETWORK_LOCATION : 0u); + d->state = s->state; + d->backend = s->backend; + d->mount_num = s->mount_num; + d->letter = (uint16_t)((s->letter >= L'A' && s->letter <= L'Z') ? s->letter : 0); + utf8_into(s->mount_id, d->mount_id, SD_STATE_ID_CCH, &cut); + utf8_into(s->profile_id, d->profile_id, SD_STATE_ID_CCH, &cut); + utf8_into(s->profile_name, d->profile_name, SD_STATE_NAME_CCH, &cut); + if (d->letter) { + wchar_t t[4]; + t[0] = (wchar_t)d->letter; + t[1] = L':'; + t[2] = 0; + (void)sd_state_set_string(d->target, SD_STATE_TARGET_CCH, t); + } + if (sd_state_set_string(d->label, SD_STATE_LABEL_CCH, s->label ? s->label : L"")) + cut = 1; + utf8_into(s->remote_url, d->remote_url, SD_STATE_URL_CCH, &cut); + if (cut) + d->flags |= SD_STATE_EF_TRUNCATED; + } + memset(&p, 0, sizeof(p)); + p.entries = g_entries; + p.entry_count = n; + p.flags = flags; + e = sd_state_writer_publish(g_writer, &p, &cuts); + if (e != SD_OK) + SD_LOGW(SP_LOG, "snapshot publish failed (%s)", sd_err_name(e)); + else if (cuts) + SD_LOGW(SP_LOG, "snapshot: %u entries cut to their slots", (unsigned)cuts); +} + +void host_state_close(void) +{ + if (g_writer == NULL) + return; + host_state_publish(NULL, 0, SD_STATE_HF_SHUTTING_DOWN); + sd_state_writer_close(g_writer); + g_writer = NULL; + sd_free(g_entries); + g_entries = NULL; +} diff --git a/apps/shelldrive-host/state_producer.h b/apps/shelldrive-host/state_producer.h new file mode 100644 index 0000000..95438f4 --- /dev/null +++ b/apps/shelldrive-host/state_producer.h @@ -0,0 +1,37 @@ +/* State snapshot producer of the agent (03 §8.2.1, M3): publishes the mounts into + * Local\com.librestatic.shelldrive.state. through sd_state. The writer is single: every call is made + * under the agent lock (g.lock), which also keeps the entry buffer below single-threaded. */ +#ifndef SD_HOST_STATE_PRODUCER_H +#define SD_HOST_STATE_PRODUCER_H + +#include +#include + +#include "sd_base/sd_err.h" + +/* One mount as the producer needs it. Strings are valid for the call (they point into the agent record). */ +typedef struct host_state_src { + uint32_t mount_num; + const char *mount_id; /* "m-N" */ + const char *profile_id; /* 32 lowercase hex digits, "" for ad hoc mounts */ + const char *profile_name; /* UTF-8 */ + const char *remote_url; /* sftp://user@host:port/path, UTF-8, never credentials; "" if none */ + const wchar_t *label; /* drive label as Explorer shows it */ + wchar_t letter; /* 'A'..'Z' */ + uint32_t state; /* SD_STATE_S_* */ + uint32_t backend; /* SD_STATE_B_* */ + int read_only; + int network_location; +} host_state_src; + +/* Creates the section and publishes an empty snapshot (a crashed agent's entries are replaced at once). + Failure (squatted section, no memory) is logged by the caller; the agent runs without a snapshot. */ +sd_err host_state_open(void); + +/* Publishes n mounts (n <= SD_STATE_MAX_ENTRIES) with the given header flags. A no-op when not open. */ +void host_state_publish(const host_state_src *items, uint32_t n, uint32_t flags); + +/* Shutdown: publishes an empty snapshot with the shutting-down flag, then closes the section. */ +void host_state_close(void); + +#endif /* SD_HOST_STATE_PRODUCER_H */ diff --git a/apps/shelldrive-host/svc_access.c b/apps/shelldrive-host/svc_access.c new file mode 100644 index 0000000..c8f912b --- /dev/null +++ b/apps/shelldrive-host/svc_access.c @@ -0,0 +1,314 @@ +/* Service access rules (see svc_access.h). Pure logic: no Win32 calls except string helpers. */ +#include +#include + +#include "svc_access.h" + +void svc_decide(const svc_member *m, svc_rights *out) +{ + int in_access = m && m->in_access; + int in_write = m && m->in_write; + int admin = m && m->admin_elevated; + memset(out, 0, sizeof(*out)); + out->see_letter = 1; /* 2.2: the letter is visible to every session anyway */ + out->see_state = admin || in_access; /* label and state: members of service_access and admins */ + out->see_details = admin; /* host, user, remote path, last error: elevated admins only */ + out->use = in_access; /* D35: no implicit default, nobody outside the list uses the drive */ + /* 03 §3.4.1: BUILTIN\Administrators always gets write (elevated, and in the list), others only when listed */ + out->write = in_access && (in_write || admin); + out->manage = admin; /* mount, unmount, secret.set, prompt.answer (2.2, TM-R25) */ +} + +/* "S-1-" followed by digits and dashes only. */ +static int sid_shape_ok(const char *s) +{ + size_t i, n = strlen(s); + if (n < 5 || n >= SVC_SID_MAX || s[0] != 'S' || s[1] != '-' || s[2] != '1' || s[3] != '-') + return 0; + for (i = 4; i < n; i++) + if (!((s[i] >= '0' && s[i] <= '9') || s[i] == '-')) + return 0; + return 1; +} + +/* Copies the next comma-separated entry of list (trimmed) into out; returns the position after it, or NULL at the end. */ +static const char *next_entry(const char *list, char *out, size_t cap, int *empty) +{ + const char *p = list, *e; + size_t n; + while (*p == ' ' || *p == '\t') p++; + e = p; + while (*e && *e != ',') e++; + n = (size_t)(e - p); + while (n > 0 && (p[n - 1] == ' ' || p[n - 1] == '\t')) n--; + *empty = (n == 0); + if (n >= cap) + n = cap - 1; + memcpy(out, p, n); + out[n] = 0; + return *e == ',' ? e + 1 : NULL; +} + +static int acl_has(const char list[][SVC_SID_MAX], uint32_t n, const char *sid) +{ + uint32_t i; + for (i = 0; i < n; i++) + if (_stricmp(list[i], sid) == 0) + return 1; + return 0; +} + +static sd_err add_sid(char list[][SVC_SID_MAX], uint32_t *n, const char *sid) +{ + if (acl_has(list, *n, sid)) + return SD_OK; + if (*n >= SVC_ACL_MAX) + return SD_E_INVALID_PARAM; + strcpy_s(list[*n], SVC_SID_MAX, sid); + (*n)++; + return SD_OK; +} + +static sd_err resolve_list(const char *csv, svc_resolve_fn resolve, void *ctx, char list[][SVC_SID_MAX], uint32_t *n) +{ + const char *p = csv; + char entry[512], sid[SVC_SID_MAX]; + int empty = 0; + *n = 0; + if (csv == NULL || csv[0] == 0) + return SD_OK; + while (p != NULL) { + sd_err e; + p = next_entry(p, entry, sizeof(entry), &empty); + if (empty) + continue; + if (sid_shape_ok(entry)) + strcpy_s(sid, sizeof(sid), entry); + else if (resolve == NULL || !resolve(ctx, entry, sid, sizeof(sid)) || !sid_shape_ok(sid)) + return SD_E_NOT_FOUND; /* unresolved name: the drive is not mounted (fail closed) */ + e = add_sid(list, n, sid); + if (e != SD_OK) + return e; + } + return SD_OK; +} + +sd_err svc_acl_build(const sd_profile *p, svc_resolve_fn resolve, void *ctx, svc_acl *out) +{ + uint32_t i; + sd_err e; + if (p == NULL || out == NULL) + return SD_E_INVALID_PARAM; + memset(out, 0, sizeof(*out)); + e = resolve_list(p->service_access, resolve, ctx, out->access, &out->n_access); + if (e != SD_OK) + return e; + e = resolve_list(p->service_access_write, resolve, ctx, out->write, &out->n_write); + if (e != SD_OK) + return e; + /* write must be a subset of access, compared by SID (names and SIDs may both appear) */ + for (i = 0; i < out->n_write; i++) + if (!acl_has(out->access, out->n_access, out->write[i])) + return SD_E_INVALID_PARAM; + return SD_OK; +} + +sd_err svc_chain_hop_check(const sd_chain_hop *h, const char **bad_key, const char **why) +{ + const char *k = NULL, *w = NULL; + if (h == NULL) + return SD_E_INVALID_PARAM; + if (h->identity_count > 1) { + k = "auth.identity"; + w = "one identity file per service hop (the hop takes one key blob)"; + } else if (h->certificate[0] != 0) { + k = "auth.certificate"; + w = "certificate files are not loaded for service profiles"; + } else if (h->identity_store[0] != 0) { + k = "auth.identity_store"; + w = "a user-kept key cannot be used by the service (service secrets only)"; + } else if (h->agent == SD_PROFILE_AGENT_PAGEANT || h->agent == SD_PROFILE_AGENT_OPENSSH) { + k = "auth.agent"; + w = "agents are not available to the service (D17)"; + } else if (h->agent_keys[0] != 0) { + k = "auth.agent_keys"; + w = "agent keys are not available to the service (D17)"; + } else if (h->auth_methods & SD_PROFILE_AUTH_KBDINT) { + k = "auth.methods"; + w = "keyboard-interactive needs a user at the console (2.6)"; + } else if ((h->auth_methods & (SD_PROFILE_AUTH_PUBLICKEY | SD_PROFILE_AUTH_PASSWORD)) == 0) { + k = "auth.methods"; + w = "no non-interactive authentication method"; + } + if (k != NULL) { + if (bad_key) *bad_key = k; + if (why) *why = w; + return SD_E_INVALID_PARAM; + } + if (bad_key) *bad_key = NULL; + if (why) *why = NULL; + return SD_OK; +} + +sd_err svc_proxy_leg_check(const sd_proxy_spec *x, int proxy_auth, int secret_present, const char **bad_key, + const char **why) +{ + const char *k = NULL, *w = NULL; + int needs_secret; + if (x == NULL) + return SD_E_INVALID_PARAM; + if (x->kind == SD_PROFILE_PROXY_SYSTEM) { + if (proxy_auth == SD_PROFILE_PROXY_AUTH_BASIC || proxy_auth == SD_PROFILE_PROXY_AUTH_NEGOTIATE) { + k = "proxy.proxy_auth"; + w = "the system proxy has no credentials (TM-R10): use auto or none"; + } + } else if (x->kind == SD_PROFILE_PROXY_HTTP || x->kind == SD_PROFILE_PROXY_SOCKS5) { + if (proxy_auth == SD_PROFILE_PROXY_AUTH_BASIC && x->user[0] == 0) { + k = "proxy.proxy_auth"; + w = "basic proxy authentication needs a user"; + } + /* SOCKS5 username/password, and HTTP with a user unless auth is none, sends a password: a service secret only */ + needs_secret = x->user[0] != 0 && + (x->kind == SD_PROFILE_PROXY_SOCKS5 || proxy_auth != SD_PROFILE_PROXY_AUTH_NONE); + if (k == NULL && needs_secret && !secret_present) { + k = "proxy.proxy_auth"; + w = "this proxy needs a password: store a proxy secret in the service secret store"; + } + } + if (k != NULL) { + if (bad_key) *bad_key = k; + if (why) *why = w; + return SD_E_INVALID_PARAM; + } + if (bad_key) *bad_key = NULL; + if (why) *why = NULL; + return SD_OK; +} + +sd_err svc_validate_profile(const sd_profile *p, const char **bad_key, const char **why) +{ + const char *k = NULL, *w = NULL; + if (p == NULL) + return SD_E_INVALID_PARAM; + if (p->scope != SD_PROFILE_SCOPE_SERVICE) { + k = "profile.scope"; + w = "not a service profile"; + } else if (p->user[0] == 0) { + k = "profile.user"; + w = "a service profile needs an explicit user name (no current user exists in the service)"; + } else if (p->service_access[0] == 0) { + k = "profile.service_access"; + w = "no service_access list (D35)"; + } else if (p->agent == SD_PROFILE_AGENT_PAGEANT || p->agent == SD_PROFILE_AGENT_OPENSSH) { + k = "auth.agent"; + w = "agents are not available to the service (D17)"; + } else if (p->auth_methods & SD_PROFILE_AUTH_KBDINT) { + k = "auth.methods"; + w = "keyboard-interactive needs a user at the console (2.6)"; + } else if (p->backend == SD_PROFILE_BACKEND_WEBDAV) { + k = "mount.backend"; + w = "global WebDAV drives are not in this build (D11)"; + } + if (k != NULL) { + if (bad_key) *bad_key = k; + if (why) *why = w; + return SD_E_INVALID_PARAM; + } + if (bad_key) *bad_key = NULL; + if (why) *why = NULL; + return SD_OK; +} + +static int sddl_append(char *out, size_t cap, size_t *len, const char *s) +{ + size_t n = strlen(s); + if (*len + n + 1 > cap) + return 0; + memcpy(out + *len, s, n + 1); + *len += n; + return 1; +} + +sd_err svc_volume_sddl(const svc_acl *acl, char *out, size_t cap) +{ + size_t len = 0; + uint32_t i; + if (acl == NULL || out == NULL || cap == 0) + return SD_E_INVALID_PARAM; + out[0] = 0; + if (!sddl_append(out, cap, &len, "O:SYG:SYD:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)")) + return SD_E_NO_SPACE; + for (i = 0; i < acl->n_access; i++) { + if (!sddl_append(out, cap, &len, "(A;OICI;FRFX;;;") || !sddl_append(out, cap, &len, acl->access[i]) || + !sddl_append(out, cap, &len, ")")) + return SD_E_NO_SPACE; + } + for (i = 0; i < acl->n_write; i++) { + if (!sddl_append(out, cap, &len, "(A;OICI;FA;;;") || !sddl_append(out, cap, &len, acl->write[i]) || + !sddl_append(out, cap, &len, ")")) + return SD_E_NO_SPACE; + } + return SD_OK; +} + +/* Substring test without wcsstr: the CRT wcsstr uses SSE2/SSE3 code, which the x86-ia32 flavour forbids (04 §3.2). */ +static int wcs_has(const wchar_t *s, const wchar_t *pat) +{ + size_t n = wcslen(pat), i; + if (n == 0) + return 1; + for (i = 0; s[i] != 0; i++) + if (wcsncmp(s + i, pat, n) == 0) + return 1; + return 0; +} + +int svc_path_under(const wchar_t *path, const wchar_t *folder) +{ + size_t fl; + if (path == NULL || folder == NULL) + return 0; + fl = wcslen(folder); + if (fl == 0 || wcsncmp(path, folder, fl) != 0) + return 0; + if (path[fl] != L'\\') + return 0; + /* no parent segments and no forward slashes after the folder prefix */ + if (wcs_has(path + fl, L"\\..\\")) + return 0; + if (wcslen(path) >= fl + 3 && wcscmp(path + wcslen(path) - 3, L"\\..") == 0) + return 0; + if (wcschr(path, L'/') != NULL) + return 0; + return 1; +} + +/* ---- Event 3101 throttle (10.3, TM-R25) ---- */ + +int svc_refuse_log_due(svc_refuse_entry *tab, size_t n, const wchar_t *sid, uint64_t now_ms) +{ + size_t i, victim = 0; + if (tab == NULL || n == 0 || sid == NULL) + return 0; + for (i = 0; i < n; i++) { + if (tab[i].used && lstrcmpW(tab[i].sid, sid) == 0) { + if (now_ms >= tab[i].last_ms && now_ms - tab[i].last_ms < SVC_REFUSE_WINDOW_MS) + return 0; + tab[i].last_ms = now_ms; + return 1; + } + } + /* not seen in the window: take a free slot, else the entry that was logged longest ago */ + for (i = 0; i < n; i++) { + if (!tab[i].used) { + victim = i; + break; + } + if (tab[i].last_ms < tab[victim].last_ms) + victim = i; + } + tab[victim].used = 1; + tab[victim].last_ms = now_ms; + wcsncpy_s(tab[victim].sid, SD_COUNTOF(tab[victim].sid), sid, _TRUNCATE); + return 1; +} diff --git a/apps/shelldrive-host/svc_access.h b/apps/shelldrive-host/svc_access.h new file mode 100644 index 0000000..ccd0d87 --- /dev/null +++ b/apps/shelldrive-host/svc_access.h @@ -0,0 +1,103 @@ +/* Service access rules for global drives (03 §1.3, §2.2, §2.6, §3.4.1; D17, D35; TM-R24, TM-R25). + * + * Pure logic, no Win32 calls: the service resolves names and token memberships, then asks these functions. + * Tests (tests/unit/sd_svc) drive every row of the decision table without a service or an elevated process. + * + * service_access who may use the drive (read; listed in the status/list output) + * service_access_write who may change files (subset of service_access; BUILTIN\Administrators in the list + * always gets write, 03 §3.4.1) + * elevated admin BUILTIN\Administrators enabled in an elevated token (the only caller that may mount, + * unmount, read details or set secrets; 2.2) + */ +#ifndef SVC_ACCESS_H +#define SVC_ACCESS_H + +#include + +#include "sd/base.h" +#include "sd_base/sd_err.h" +#include "sd_config/sd_config.h" + +#define SVC_SID_MAX 190 /* characters of a SID string, with the terminator */ +#define SVC_ACL_MAX 32 /* entries per list */ + +/* Resolved access lists of one machine profile: SID strings only ("S-1-5-..."). write[] is a subset of access[]. */ +typedef struct svc_acl { + uint32_t n_access, n_write; + char access[SVC_ACL_MAX][SVC_SID_MAX]; + char write[SVC_ACL_MAX][SVC_SID_MAX]; +} svc_acl; + +/* What the caller is, as far as the decision needs it. */ +typedef struct svc_member { + int in_access; /* the caller's token matches an entry of service_access (user SID or enabled group) */ + int in_write; /* ... of service_access_write */ + int admin_elevated; /* elevated BUILTIN\Administrators (sd_ipc_client_is_admin) */ +} svc_member; + +/* What the caller may do with one global mount. */ +typedef struct svc_rights { + int see_letter; /* the letter (always: it is visible system-wide, 2.2) */ + int see_state; /* label and state (service_access members and admins) */ + int see_details; /* host, user, remote path, errors (elevated admins only) */ + int use; /* read the drive */ + int write; /* change files */ + int manage; /* mount, unmount, secret.set, prompt.answer (elevated admins only) */ +} svc_rights; + +void svc_decide(const svc_member *m, svc_rights *out); + +/* Resolves one service_access entry (a SID string, or an account name) to a SID string. Returns 1 when resolved. */ +typedef int (*svc_resolve_fn)(void *ctx, const char *entry, char *sid_out, size_t cap); + +/* Builds the resolved lists from the profile's service_access and service_access_write. SD_E_NOT_FOUND when an entry + does not resolve (fail closed), SD_E_INVALID_PARAM when a write entry is not in the access list or the lists are + too long. */ +sd_err svc_acl_build(const sd_profile *p, svc_resolve_fn resolve, void *ctx, svc_acl *out); + +/* Machine profile checks at load and before every mount (2.6 rule 1, D17, 03 §3.4.1). Returns SD_OK, or SD_E_INVALID_PARAM + with *bad_key naming the offending key and *why a short English reason (for logs). Refused: scope other than service, + no user, agent-based authentication (auth.agent = pageant or openssh, D17), kbdint (2.6), WebDAV backend (the global + WebDAV spike is not in this build, D11). Jump hosts and proxies are checked per hop when the chain is built + (svc_chain_hop_check, svc_proxy_leg_check). */ +sd_err svc_validate_profile(const sd_profile *p, const char **bad_key, const char **why); + +/* One hop of a service chain (2.6 rule 1, M5, 3.4.1): the service rules of the hop. Refused: more than one identity + file (a hop takes one key blob), a certificate, identity_store (a user-kept key, service secrets only), an agent + (Pageant or OpenSSH, D17), agent_keys, keyboard-interactive, and no publickey or password method. */ +sd_err svc_chain_hop_check(const sd_chain_hop *h, const char **bad_key, const char **why); + +/* One proxy leg of a service chain (2.6 rule 1, 3.8). proxy_auth is the profile's SD_PROFILE_PROXY_AUTH_*; + secret_present says whether a proxy secret for this leg is in the service secret store. Allowed: no proxy; a + proxy with no user (Negotiate through SSPI with the machine account; auto is Kerberos only); a user (HTTP Basic or + SOCKS5 username and password) only with a service secret. Refused: basic without a user or without the secret, and + proxy = system with basic or negotiate (the machine configuration has no credentials, TM-R10). */ +sd_err svc_proxy_leg_check(const sd_proxy_spec *x, int proxy_auth, int secret_present, const char **bad_key, + const char **why); + +/* Volume security descriptor of a global mount, SDDL: SYSTEM and Administrators full control, each access entry + read/traverse (FRFX), each write entry full control (FA), all inherited to containers and objects. Writes + "O:SYG:SY" and the DACL into out. SD_E_NO_SPACE when cap is too small. */ +sd_err svc_volume_sddl(const svc_acl *acl, char *out, size_t cap); + +/* Machine path rule for identity files of service profiles (TM-R27, 2.6): the expanded path must begin with the + machine service folder followed by a backslash and must not contain a reparse-ambiguous "..\" segment. */ +int svc_path_under(const wchar_t *path, const wchar_t *folder); + +/* Event 3101 throttle (10.3, TM-R25): one entry per SID that was refused or over quota. */ +#define SVC_REFUSE_WINDOW_MS (10u * 60u * 1000u) +#define SVC_REFUSE_SLOTS 32 +#define SVC_REFUSE_SID_CCH 96 + +typedef struct svc_refuse_entry { + int used; + uint64_t last_ms; /* when the event was last written for this SID */ + wchar_t sid[SVC_REFUSE_SID_CCH]; /* SID string */ +} svc_refuse_entry; + +/* 1 when an Event 3101 may be written for sid now (not written for it in the last 10 minutes), and records it; + 0 otherwise. tab holds n entries; when full, the entry logged longest ago is reused. now_ms is the caller's + monotonic clock (tests pass their own times). Not thread-safe: the caller serializes it. */ +int svc_refuse_log_due(svc_refuse_entry *tab, size_t n, const wchar_t *sid, uint64_t now_ms); + +#endif /* SVC_ACCESS_H */ diff --git a/apps/shelldrive-host/svc_folder.c b/apps/shelldrive-host/svc_folder.c new file mode 100644 index 0000000..4f1003e --- /dev/null +++ b/apps/shelldrive-host/svc_folder.c @@ -0,0 +1,335 @@ +/* Machine folder and key file trust (03 §3.2, 2.6 rule 1, TM-R27). See svc_folder.h. */ +#include +#include +#include +#include + +#include "sd_base/sd_mem.h" +#include "svc_folder.h" + +/* Largest key file the service reads (identity and certificate files are a few KiB). */ +#define SVC_FILE_MAX (256u * 1024u) + +/* Rights that let a principal change a folder or file: write data or append, write attributes or EAs, delete, change + the DACL or the owner, and the generic write and all bits. */ +#define SVC_WRITE_RIGHTS (FILE_WRITE_DATA | FILE_APPEND_DATA | FILE_WRITE_EA | FILE_WRITE_ATTRIBUTES | DELETE | \ + WRITE_DAC | WRITE_OWNER | GENERIC_WRITE | GENERIC_ALL) + +int svc_folder_owner_shipping(void *ctx, PSID owner) +{ + (void)ctx; + if (owner == NULL) + return 0; + return (IsWellKnownSid(owner, WinLocalSystemSid) || IsWellKnownSid(owner, WinBuiltinAdministratorsSid)) ? 1 : 0; +} + +DWORD svc_folder_open(const wchar_t *path, DWORD access, HANDLE *h) +{ + *h = CreateFileW(path, access, FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, NULL, OPEN_EXISTING, + FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_BACKUP_SEMANTICS, NULL); + if (*h == INVALID_HANDLE_VALUE) { + *h = NULL; + return GetLastError(); + } + return 0; +} + +/* 1 when the owner of the open handle passes owner_ok. An unreadable owner is untrusted. */ +static int owner_passes(svc_owner_fn owner_ok, void *ctx, HANDLE h) +{ + PSID owner = NULL; + PSECURITY_DESCRIPTOR sd = NULL; + int r = 0; + if (GetSecurityInfo(h, SE_FILE_OBJECT, OWNER_SECURITY_INFORMATION, &owner, NULL, NULL, NULL, &sd) != ERROR_SUCCESS) + return 0; + r = (owner != NULL && owner_ok(ctx, owner)) ? 1 : 0; + if (sd != NULL) + LocalFree(sd); + return r; +} + +DWORD svc_folder_inspect(HANDLE h, int want_dir, svc_owner_fn owner_ok, void *ctx, svc_folder_reason *why) +{ + BY_HANDLE_FILE_INFORMATION info; + *why = SVC_FOLDER_OK; + if (!GetFileInformationByHandle(h, &info)) + return GetLastError(); + if (info.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) { + *why = SVC_FOLDER_REPARSE; + return 0; + } + if (want_dir != ((info.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) != 0)) { + *why = SVC_FOLDER_KIND; + return 0; + } + if (want_dir && !owner_passes(owner_ok != NULL ? owner_ok : svc_folder_owner_shipping, ctx, h)) + *why = SVC_FOLDER_OWNER; + return 0; +} + +/* 1 when sid is the pseudo principal S-1-3-sub (CREATOR OWNER is S-1-3-0, OWNER RIGHTS is S-1-3-4). */ +static int sid_is_creator_pseudo(PSID sid, BYTE sub) +{ + static const SID_IDENTIFIER_AUTHORITY creator = {{0, 0, 0, 0, 0, 3}}; + SID_IDENTIFIER_AUTHORITY *a; + if (!IsValidSid(sid)) + return 0; + a = GetSidIdentifierAuthority(sid); + return (a != NULL && memcmp(a, &creator, sizeof(*a)) == 0 && *GetSidSubAuthorityCount(sid) == 1 && + *GetSidSubAuthority(sid, 0) == sub) ? 1 : 0; +} + +/* The principals that may hold write rights (3.2): SYSTEM, Administrators, the owner (already trusted by the owner + rule), CREATOR OWNER and OWNER RIGHTS (both resolve to the owner). */ +static int principal_may_write(PSID sid, PSID owner) +{ + if (IsWellKnownSid(sid, WinLocalSystemSid) || IsWellKnownSid(sid, WinBuiltinAdministratorsSid)) + return 1; + if (sid_is_creator_pseudo(sid, 0) || sid_is_creator_pseudo(sid, 4)) + return 1; + return (owner != NULL && EqualSid(sid, owner)) ? 1 : 0; +} + +/* 1 when an untrusted principal holds a write right in dacl. A NULL DACL (everyone) and an ACE type this check does not + understand (object and callback ACEs) fail closed. Deny ACEs are not counted. */ +static int dacl_untrusted_write(PACL dacl, PSID owner) +{ + ACL_SIZE_INFORMATION info; + DWORD i; + if (dacl == NULL) + return 1; + if (!GetAclInformation(dacl, &info, sizeof(info), AclSizeInformation)) + return 1; + for (i = 0; i < info.AceCount; i++) { + LPVOID p = NULL; + ACE_HEADER *hdr; + ACCESS_ALLOWED_ACE *a; + if (!GetAce(dacl, i, &p) || p == NULL) + return 1; + hdr = (ACE_HEADER *)p; + if (hdr->AceType == ACCESS_DENIED_ACE_TYPE) + continue; + if (hdr->AceType != ACCESS_ALLOWED_ACE_TYPE) + return 1; + a = (ACCESS_ALLOWED_ACE *)p; + if ((a->Mask & SVC_WRITE_RIGHTS) != 0 && !principal_may_write((PSID)&a->SidStart, owner)) + return 1; + } + return 0; +} + +/* Owner and DACL of an open file or folder, both read from the handle. Sets *why: OWNER when the owner is untrusted or + cannot be read (fail closed), ACL when the DACL lets an untrusted principal write, OK otherwise. */ +static void handle_owner_and_dacl(HANDLE h, svc_owner_fn owner_ok, void *ctx, svc_folder_reason *why) +{ + PSID owner = NULL; + PACL dacl = NULL; + PSECURITY_DESCRIPTOR sd = NULL; + DWORD e = GetSecurityInfo(h, SE_FILE_OBJECT, OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION, &owner, NULL, + &dacl, NULL, &sd); + *why = SVC_FOLDER_OK; + if (e != ERROR_SUCCESS) { + *why = SVC_FOLDER_OWNER; + } else if (owner == NULL || !(owner_ok != NULL ? owner_ok(ctx, owner) : svc_folder_owner_shipping(ctx, owner))) { + *why = SVC_FOLDER_OWNER; + } else if (dacl_untrusted_write(dacl, owner)) { + *why = SVC_FOLDER_ACL; + } + if (sd != NULL) + LocalFree(sd); +} + +DWORD svc_store_check(const wchar_t *path, svc_owner_fn owner_ok, void *ctx, svc_folder_reason *why) +{ + HANDLE h = NULL; + DWORD e = svc_folder_open(path, READ_CONTROL | FILE_READ_ATTRIBUTES, &h); + *why = SVC_FOLDER_OK; + if (e == ERROR_FILE_NOT_FOUND || e == ERROR_PATH_NOT_FOUND) + return 0; /* not created yet: the write creates it inside the checked data folder */ + if (e != 0) { + *why = SVC_FOLDER_NO_RIGHTS; + return e; + } + e = svc_folder_inspect(h, 0, owner_ok, ctx, why); + if (e == 0 && *why == SVC_FOLDER_OK) + handle_owner_and_dacl(h, owner_ok, ctx, why); + CloseHandle(h); + return e; +} + +/* 1 when the DACL want equals have, ACE by ACE in order (the inherited flag is not compared). */ +static int same_dacl(PACL want, PACL have) +{ + ACL_SIZE_INFORMATION wi, hi; + DWORD i; + if (want == NULL || have == NULL) + return 0; + if (!GetAclInformation(want, &wi, sizeof(wi), AclSizeInformation) || + !GetAclInformation(have, &hi, sizeof(hi), AclSizeInformation)) + return 0; + if (wi.AceCount != hi.AceCount) + return 0; + for (i = 0; i < wi.AceCount; i++) { + LPVOID wp = NULL, hp = NULL; + ACE_HEADER *wh, *hh; + if (!GetAce(want, i, &wp) || !GetAce(have, i, &hp) || wp == NULL || hp == NULL) + return 0; + wh = (ACE_HEADER *)wp; + hh = (ACE_HEADER *)hp; + if (wh->AceType != hh->AceType) + return 0; + if (wh->AceType != ACCESS_ALLOWED_ACE_TYPE && wh->AceType != ACCESS_DENIED_ACE_TYPE) + return 0; + if ((wh->AceFlags & ~INHERITED_ACE) != (hh->AceFlags & ~INHERITED_ACE)) + return 0; + if (((ACCESS_ALLOWED_ACE *)wp)->Mask != ((ACCESS_ALLOWED_ACE *)hp)->Mask) + return 0; + if (!EqualSid((PSID)&((ACCESS_ALLOWED_ACE *)wp)->SidStart, (PSID)&((ACCESS_ALLOWED_ACE *)hp)->SidStart)) + return 0; + } + return 1; +} + +DWORD svc_folder_apply_dacl(HANDLE h, const wchar_t *sddl, int *changed) +{ + PSECURITY_DESCRIPTOR want_sd = NULL, have_sd = NULL; + PACL want = NULL, have = NULL; + BOOL present = FALSE, defaulted = FALSE; + SECURITY_DESCRIPTOR_CONTROL want_ctl = 0, have_ctl = 0; + DWORD rev = 0, e; + int same; + *changed = 0; + if (!ConvertStringSecurityDescriptorToSecurityDescriptorW(sddl, SDDL_REVISION_1, &want_sd, NULL)) + return GetLastError(); + if (!GetSecurityDescriptorDacl(want_sd, &present, &want, &defaulted) || !present || + !GetSecurityDescriptorControl(want_sd, &want_ctl, &rev)) { + LocalFree(want_sd); + return ERROR_INVALID_PARAMETER; + } + e = GetSecurityInfo(h, SE_FILE_OBJECT, DACL_SECURITY_INFORMATION, NULL, NULL, &have, NULL, &have_sd); + if (e != ERROR_SUCCESS) { + LocalFree(want_sd); + return e; + } + same = same_dacl(want, have) && have_sd != NULL && GetSecurityDescriptorControl(have_sd, &have_ctl, &rev) && + ((have_ctl & SE_DACL_PROTECTED) == (want_ctl & SE_DACL_PROTECTED)); + if (have_sd != NULL) + LocalFree(have_sd); + if (!same) { + e = SetSecurityInfo(h, SE_FILE_OBJECT, DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION, NULL, + NULL, want, NULL); + if (e == ERROR_SUCCESS) + *changed = 1; + } + LocalFree(want_sd); + return e; +} + +/* A folder between base and a key file: reparse point, kind, owner, then its DACL (the same rule as the file). */ +static DWORD folder_trust(HANDLE h, svc_owner_fn owner_ok, void *ctx, svc_folder_reason *why) +{ + DWORD e = svc_folder_inspect(h, 1, owner_ok, ctx, why); + if (e != 0 || *why != SVC_FOLDER_OK) + return e; + handle_owner_and_dacl(h, owner_ok, ctx, why); + return 0; +} + +DWORD svc_file_read_trusted(const wchar_t *base, const wchar_t *path, svc_owner_fn owner_ok, void *ctx, + unsigned char **data, DWORD *len, svc_folder_reason *why) +{ + size_t blen, plen, i; + wchar_t prefix[MAX_PATH]; + BY_HANDLE_FILE_INFORMATION info; + LARGE_INTEGER size; + HANDLE h = NULL; + unsigned char *buf; + DWORD got = 0, total = 0, e = 0; + *data = NULL; + *len = 0; + *why = SVC_FOLDER_NO_RIGHTS; + if (base == NULL || path == NULL) + return ERROR_INVALID_PARAMETER; + blen = wcslen(base); + plen = wcslen(path); + if (blen == 0 || plen <= blen + 1 || wcsncmp(path, base, blen) != 0 || path[blen] != L'\\') + return ERROR_INVALID_PARAMETER; + /* every folder between base and the file, checked by handle, in order */ + for (i = blen + 1; i < plen; i++) { + if (path[i] != L'\\') + continue; + if (i + 1 == plen || path[i - 1] == L'\\' || i >= MAX_PATH) + return ERROR_INVALID_PARAMETER; + memcpy(prefix, path, i * sizeof(wchar_t)); + prefix[i] = 0; + e = svc_folder_open(prefix, READ_CONTROL | FILE_READ_ATTRIBUTES, &h); + if (e != 0) + return e; + e = folder_trust(h, owner_ok, ctx, why); + CloseHandle(h); + if (e != 0 || *why != SVC_FOLDER_OK) + return e; + } + /* the file: no link is followed, and the share mode gives no writers while it is read */ + /* BACKUP_SEMANTICS lets a folder open here too, so a folder where a key file belongs gets the kind refusal below + instead of an access error (no privilege is used: the DACL still applies) */ + h = CreateFileW(path, GENERIC_READ, FILE_SHARE_READ, NULL, OPEN_EXISTING, + FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_BACKUP_SEMANTICS, NULL); + if (h == INVALID_HANDLE_VALUE) + return GetLastError(); + if (!GetFileInformationByHandle(h, &info)) { + e = GetLastError(); + CloseHandle(h); + return e; + } + if (info.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) + *why = SVC_FOLDER_REPARSE; + else if (info.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) + *why = SVC_FOLDER_KIND; + else + handle_owner_and_dacl(h, owner_ok, ctx, why); + if (*why != SVC_FOLDER_OK) { + CloseHandle(h); + return 0; + } + if (!GetFileSizeEx(h, &size) || size.QuadPart <= 0 || size.QuadPart > (LONGLONG)SVC_FILE_MAX) { + CloseHandle(h); + *why = SVC_FOLDER_NOT_FILE; + return 0; + } + got = size.LowPart; + buf = (unsigned char *)sd_alloc(got); + if (buf == NULL) { + CloseHandle(h); + return ERROR_NOT_ENOUGH_MEMORY; + } + while (total < got) { + DWORD n = 0; + if (!ReadFile(h, buf + total, got - total, &n, NULL) || n == 0) + break; + total += n; + } + CloseHandle(h); + if (total != got) { + sd_secure_free(buf, got); + *why = SVC_FOLDER_NO_RIGHTS; + return ERROR_READ_FAULT; + } + *data = buf; + *len = got; + *why = SVC_FOLDER_OK; + return 0; +} + +const wchar_t *svc_folder_reason_text(svc_folder_reason why) +{ + switch (why) { + case SVC_FOLDER_OWNER: return L"owner"; + case SVC_FOLDER_REPARSE: return L"reparse_point"; + case SVC_FOLDER_KIND: return L"not_directory"; + case SVC_FOLDER_NO_RIGHTS: return L"acl"; + case SVC_FOLDER_ACL: return L"acl"; + case SVC_FOLDER_NOT_FILE: return L"not_file"; + default: return L"ok"; + } +} diff --git a/apps/shelldrive-host/svc_folder.h b/apps/shelldrive-host/svc_folder.h new file mode 100644 index 0000000..ff42723 --- /dev/null +++ b/apps/shelldrive-host/svc_folder.h @@ -0,0 +1,61 @@ +/* Machine folder and key file trust (03 §3.2, 2.6 rule 1, TM-R34, TM-R27, T-63, T-51): the checks shared by the service + * start (service.c), the data folder repair (svc_repair.c) and the reads of identity and certificate files. Every check + * runs on a handle opened with FILE_FLAG_OPEN_REPARSE_POINT, so a link is never followed. A folder is trusted when it is + * a directory, not a reparse point, owned by a trusted SID (SYSTEM or BUILTIN\Administrators in the shipping build) and + * (for key files and the folders above them) its DACL grants no write, modify, delete, WRITE_DAC or WRITE_OWNER to a + * principal outside the trusted set. */ +#ifndef SVC_FOLDER_H +#define SVC_FOLDER_H + +#include + +/* Owner rule seam: 1 when the owner is trusted. NULL in the callers means svc_folder_owner_shipping. */ +typedef int (*svc_owner_fn)(void *ctx, PSID owner); + +/* Why a path is not trusted. OK means trusted. */ +typedef enum svc_folder_reason { + SVC_FOLDER_OK = 0, + SVC_FOLDER_OWNER, /* the owner is not trusted, or the owner cannot be read (fail closed) */ + SVC_FOLDER_REPARSE, /* a link or a junction: never followed */ + SVC_FOLDER_KIND, /* a file where a folder belongs, or a folder where a store file belongs */ + SVC_FOLDER_NO_RIGHTS, /* the path cannot be opened or created with the rights the service needs */ + SVC_FOLDER_ACL, /* the DACL grants write, modify, delete, WRITE_DAC or WRITE_OWNER to an untrusted principal, + or cannot be set back to the 3.2 DACL */ + SVC_FOLDER_NOT_FILE /* a key file that is a folder, or empty, or larger than the read limit */ +} svc_folder_reason; + +/* The shipping owner rule: SYSTEM or BUILTIN\Administrators. ctx is ignored. */ +int svc_folder_owner_shipping(void *ctx, PSID owner); + +/* Opens path for the checks (never follows a link). Returns 0, or the Win32 error: ERROR_FILE_NOT_FOUND and + ERROR_PATH_NOT_FOUND mean the path is missing. *h is NULL on error. */ +DWORD svc_folder_open(const wchar_t *path, DWORD access, HANDLE *h); + +/* Inspects an open handle. want_dir = 1 checks a folder and its owner (owner_ok, NULL = shipping rule); 0 checks a + store file (kind and reparse point only). Returns 0 with *why set, or the Win32 error of the query. */ +DWORD svc_folder_inspect(HANDLE h, int want_dir, svc_owner_fn owner_ok, void *ctx, svc_folder_reason *why); + +/* Makes the DACL of an open folder equal to the SDDL text (3.2: protected, the listed ACEs in order). The current DACL + is compared ACE by ACE first; changed is set to 1 only when it was rewritten. Needs WRITE_DAC on the handle when it + differs. Returns 0, or the Win32 error (a refusal: the caller reports SVC_FOLDER_ACL). */ +DWORD svc_folder_apply_dacl(HANDLE h, const wchar_t *sddl, int *changed); + +/* Checks a store file the service writes (the machine known_hosts, 5.1, 5.6, TM-R27) before it is changed: opened + without following a link, a plain file whose owner is trusted and whose DACL grants no write to an untrusted + principal. A missing file is trusted (the write creates it in the checked data folder). Returns 0 with *why set, or + the Win32 error of the open. */ +DWORD svc_store_check(const wchar_t *path, svc_owner_fn owner_ok, void *ctx, svc_folder_reason *why); + +/* Reads a key file the service loads (identity and certificate files, 2.6 rule 1, TM-R27). path must be under base + (the machine service folder). Every folder between base and the file is checked (reparse point, owner, DACL) by + handle; the file is opened without following a link, checked on that same handle (plain file, owner, DACL) and read + from that handle, and the share mode of the open excludes writers until the read is done. + Returns 0 with *why = SVC_FOLDER_OK and *data / *len set (free with sd_secure_free), or 0 with *why set to the refusal, + or a Win32 error when the file cannot be opened or read (*why = SVC_FOLDER_NO_RIGHTS). */ +DWORD svc_file_read_trusted(const wchar_t *base, const wchar_t *path, svc_owner_fn owner_ok, void *ctx, + unsigned char **data, DWORD *len, svc_folder_reason *why); + +/* The Event Log insert for a reason (10.3, `%2` of Event 3102): owner, reparse_point, not_directory, acl, not_file. */ +const wchar_t *svc_folder_reason_text(svc_folder_reason why); + +#endif /* SVC_FOLDER_H */ diff --git a/apps/shelldrive-host/svc_gw.c b/apps/shelldrive-host/svc_gw.c new file mode 100644 index 0000000..8cc07ee --- /dev/null +++ b/apps/shelldrive-host/svc_gw.c @@ -0,0 +1,196 @@ +/* Global WebDAV mechanism choice (apps/shelldrive-host/svc_gw.h). Pure: no Win32 calls except the version constants. */ +#include + +#include "svc_gw.h" + +/* The compiled table. Every cell is UNVERIFIED, so no OS family selects system or link yet and auto resolves to attach + everywhere. Why, per mechanism (the evidence is in docs/dev/m5-webdav-global-spike.md): + + system (mechanism A): a drive mapped by LocalSystem belongs to the SYSTEM logon session. The redirector then carries + user accesses on that connection, so the server would see the service identity, not the accessing user (G2). This + is reasoning from the redirector model; no lab run has confirmed it on any OS. On XP/2003 the design expects global + visibility (D11), but G2 is the open part there too. UNVERIFIED on XP/2003, UNVERIFIED on the others. + + link (mechanism B): a global DOS device on the UNC lets each user's logon session create the implicit connection + with its own credentials (G2 by design). The session-local form is accepted without elevation (verified on this + machine, Windows 11), but the Global\ form that the service needs is denied to a standard user (verified), and + G1, G3 and G5 need a LocalSystem process and a second logon session, which this machine cannot provide. UNVERIFIED + on every family. + + Only a lab run (G1-G5 and G7, 02 §7.7) may change a cell to GO, and only with a note in the spike report. */ +static const svc_gw_table k_compiled = { + { SVC_GW_UNVERIFIED /* unknown */, SVC_GW_UNVERIFIED /* XP/2003 */, SVC_GW_UNVERIFIED /* Vista/7 */, + SVC_GW_UNVERIFIED /* 8/8.1 */, SVC_GW_UNVERIFIED /* 10/11 */ }, + { SVC_GW_UNVERIFIED /* unknown */, SVC_GW_UNVERIFIED /* XP/2003 */, SVC_GW_UNVERIFIED /* Vista/7 */, + SVC_GW_UNVERIFIED /* 8/8.1 */, SVC_GW_UNVERIFIED /* 10/11 */ } +}; + +svc_gw_family svc_gw_family_of(DWORD major, DWORD minor) +{ + if (major == 5 && (minor == 1 || minor == 2)) + return SVC_GW_OS_XP_2003; + if (major == 6 && (minor == 0 || minor == 1)) + return SVC_GW_OS_VISTA_7; + if (major == 6 && (minor == 2 || minor == 3)) + return SVC_GW_OS_8_81; + if (major == 10 && minor == 0) + return SVC_GW_OS_10_11; + return SVC_GW_OS_UNKNOWN; +} + +svc_gw_mode svc_gw_mode_parse(const char *s) +{ + if (s == NULL) + return SVC_GW_MODE_INVALID; + if (strcmp(s, "auto") == 0) + return SVC_GW_MODE_AUTO; + if (strcmp(s, "system") == 0) + return SVC_GW_MODE_SYSTEM; + if (strcmp(s, "link") == 0) + return SVC_GW_MODE_LINK; + if (strcmp(s, "attach") == 0) + return SVC_GW_MODE_ATTACH; + return SVC_GW_MODE_INVALID; +} + +const char *svc_gw_family_name(svc_gw_family f) +{ + switch (f) { + case SVC_GW_OS_XP_2003: return "xp-2003"; + case SVC_GW_OS_VISTA_7: return "vista-7"; + case SVC_GW_OS_8_81: return "8-8.1"; + case SVC_GW_OS_10_11: return "10-11"; + default: return "unknown"; + } +} + +const char *svc_gw_mode_name(svc_gw_mode m) +{ + switch (m) { + case SVC_GW_MODE_AUTO: return "auto"; + case SVC_GW_MODE_SYSTEM: return "system"; + case SVC_GW_MODE_LINK: return "link"; + case SVC_GW_MODE_ATTACH: return "attach"; + default: return "invalid"; + } +} + +const char *svc_gw_mech_name(svc_gw_mech m) +{ + switch (m) { + case SVC_GW_SYSTEM: return "system"; + case SVC_GW_LINK: return "link"; + case SVC_GW_ATTACH: return "attach"; + default: return "refuse"; + } +} + +const char *svc_gw_reason_name(svc_gw_reason r) +{ + switch (r) { + case SVC_GW_R_OK: return "ok"; + case SVC_GW_R_TOKEN_ONLY: return "token-only mode is never used for a global mount"; + case SVC_GW_R_MODE: return "webdav_global_mode is not auto, system, link or attach"; + case SVC_GW_R_SERVICE_DOWN: return "the service is not running"; + case SVC_GW_R_OS: return "the Windows version is not one of the supported families"; + case SVC_GW_R_CLIENT_MISSING: return "the WebClient service is not installed"; + case SVC_GW_R_CLIENT_DISABLED: return "the WebClient service is disabled"; + case SVC_GW_R_CLIENT_UNKNOWN: return "the WebClient service state could not be read"; + case SVC_GW_R_NOT_VERIFIED_SYSTEM: return "the system mechanism is not verified on this Windows version"; + case SVC_GW_R_NOT_VERIFIED_LINK: return "the link mechanism is not verified on this Windows version"; + } + return "unknown"; +} + +const char *svc_gw_reason_code(svc_gw_reason r) +{ + switch (r) { + case SVC_GW_R_OK: return ""; + case SVC_GW_R_TOKEN_ONLY: return "usage"; + case SVC_GW_R_MODE: return "usage"; + case SVC_GW_R_CLIENT_DISABLED: return "webclient_disabled"; + default: return "backend_unavailable"; + } +} + +const svc_gw_table *svc_gw_compiled_table(void) +{ + return &k_compiled; +} + +static void refuse(svc_gw_verdict *out, svc_gw_reason r) +{ + out->mech = SVC_GW_REFUSE; + out->reason = r; +} + +void svc_gw_decide_with(const svc_gw_table *t, const svc_gw_input *in, svc_gw_verdict *out) +{ + svc_gw_family f; + int sys_go, link_go; + + if (out == NULL) + return; + refuse(out, SVC_GW_R_OS); + if (t == NULL || in == NULL) + return; + + /* Order matters for the log: the policy refusals come first, then the machine state. */ + if (in->token_only) { + refuse(out, SVC_GW_R_TOKEN_ONLY); + return; + } + if (in->mode == SVC_GW_MODE_INVALID) { + refuse(out, SVC_GW_R_MODE); + return; + } + if (!in->service_running) { + refuse(out, SVC_GW_R_SERVICE_DOWN); + return; + } + f = in->family; + if (f <= SVC_GW_OS_UNKNOWN || f >= SVC_GW_OS_COUNT) { + refuse(out, SVC_GW_R_OS); + return; + } + switch (in->client) { + case SVC_GW_CLIENT_MISSING: refuse(out, SVC_GW_R_CLIENT_MISSING); return; + case SVC_GW_CLIENT_DISABLED: refuse(out, SVC_GW_R_CLIENT_DISABLED); return; + case SVC_GW_CLIENT_UNKNOWN: refuse(out, SVC_GW_R_CLIENT_UNKNOWN); return; + case SVC_GW_CLIENT_RUNNING: + case SVC_GW_CLIENT_STOPPED: + default: break; + } + + sys_go = t->system[f] == SVC_GW_GO; + link_go = t->link[f] == SVC_GW_GO; + switch (in->mode) { + case SVC_GW_MODE_SYSTEM: + if (!sys_go) { refuse(out, SVC_GW_R_NOT_VERIFIED_SYSTEM); return; } + out->mech = SVC_GW_SYSTEM; + out->reason = SVC_GW_R_OK; + return; + case SVC_GW_MODE_LINK: + if (!link_go) { refuse(out, SVC_GW_R_NOT_VERIFIED_LINK); return; } + out->mech = SVC_GW_LINK; + out->reason = SVC_GW_R_OK; + return; + case SVC_GW_MODE_ATTACH: + out->mech = SVC_GW_ATTACH; + out->reason = SVC_GW_R_OK; + return; + case SVC_GW_MODE_AUTO: + default: + /* the first GO mechanism of the table, system before link (03 §1.3), else the per-user attach */ + if (sys_go) out->mech = SVC_GW_SYSTEM; + else if (link_go) out->mech = SVC_GW_LINK; + else out->mech = SVC_GW_ATTACH; + out->reason = SVC_GW_R_OK; + return; + } +} + +void svc_gw_decide(const svc_gw_input *in, svc_gw_verdict *out) +{ + svc_gw_decide_with(svc_gw_compiled_table(), in, out); +} diff --git a/apps/shelldrive-host/svc_gw.h b/apps/shelldrive-host/svc_gw.h new file mode 100644 index 0000000..6896b9f --- /dev/null +++ b/apps/shelldrive-host/svc_gw.h @@ -0,0 +1,113 @@ +/* Global WebDAV mechanism choice for service profiles (02 §7.7, 03 §1.3; D11, TM-R19). + * + * Pure logic, no Win32 calls: the caller reads the OS version, the settings and the service and WebClient state, + * then asks svc_gw_decide. tests/unit/sd_svc drives the table without a service, an SCM or a second session. + * + * system the service maps the drive as LocalSystem (02 §7.7 mechanism A) + * link the service defines a global DOS device on the UNC (mechanism B) + * attach no drive before logon; each user's agent maps the UNC in its own logon session (02 §7.7 fallback) + * refuse the mount fails with the IPC code of svc_gw_reason_code + * + * A mechanism is chosen only when its cell in the table is GO for the OS family. UNVERIFIED and NOGO cells are never + * chosen; an explicit system or link request for such a cell is refused, it does not degrade to attach. Token-only + * mode is refused for every global mount (TM-R19), whatever the table says. */ +#ifndef SVC_GW_H +#define SVC_GW_H + +#include + +/* OS families of the M5 lab matrix (02 §7.7). Windows 8 (6.2) and 8.1 (6.3) share one column. Unknown covers + Windows 2000 and older, Windows 10 preview builds reporting 6.4, and anything not listed. */ +typedef enum svc_gw_family { + SVC_GW_OS_UNKNOWN = 0, + SVC_GW_OS_XP_2003, /* 5.1 (XP), 5.2 (Server 2003, XP x64) */ + SVC_GW_OS_VISTA_7, /* 6.0 (Vista), 6.1 (7, Server 2008 R2) */ + SVC_GW_OS_8_81, /* 6.2 (8, Server 2012), 6.3 (8.1, Server 2012 R2) */ + SVC_GW_OS_10_11, /* 10.0 (10, 11, Server 2016+) */ + SVC_GW_OS_COUNT +} svc_gw_family; + +/* settings.ini [service] webdav_global_mode, parsed. INVALID is refused (usage). */ +typedef enum svc_gw_mode { + SVC_GW_MODE_INVALID = -1, + SVC_GW_MODE_AUTO = 0, + SVC_GW_MODE_SYSTEM, + SVC_GW_MODE_LINK, + SVC_GW_MODE_ATTACH +} svc_gw_mode; + +/* Lab result of one mechanism on one OS family. Only GO is ever used. */ +typedef enum svc_gw_status { + SVC_GW_UNVERIFIED = 0, /* not proven here (the spike could not run the lab criteria G1-G7) */ + SVC_GW_GO, /* G1-G5 and G7 passed for this OS (02 §7.7) */ + SVC_GW_NOGO /* a criterion failed in the lab */ +} svc_gw_status; + +/* WebClient service state, as the read-only SCM query reports it (sd_webdav_client_state, without the header). */ +typedef enum svc_gw_client { + SVC_GW_CLIENT_UNKNOWN = 0, + SVC_GW_CLIENT_RUNNING, + SVC_GW_CLIENT_STOPPED, + SVC_GW_CLIENT_DISABLED, + SVC_GW_CLIENT_MISSING +} svc_gw_client; + +typedef enum svc_gw_mech { + SVC_GW_REFUSE = 0, + SVC_GW_SYSTEM, + SVC_GW_LINK, + SVC_GW_ATTACH +} svc_gw_mech; + +typedef enum svc_gw_reason { + SVC_GW_R_OK = 0, + SVC_GW_R_TOKEN_ONLY, /* TM-R19: never for a global mount */ + SVC_GW_R_MODE, /* webdav_global_mode is not one of the four names */ + SVC_GW_R_SERVICE_DOWN, /* the service core does not hold the pipe */ + SVC_GW_R_OS, /* OS family unknown */ + SVC_GW_R_CLIENT_MISSING, /* WebClient not installed (Server: WebDAV-Redirector / Desktop Experience) */ + SVC_GW_R_CLIENT_DISABLED, /* WebClient start type disabled (needs an administrator) */ + SVC_GW_R_CLIENT_UNKNOWN, /* the SCM could not be queried */ + SVC_GW_R_NOT_VERIFIED_SYSTEM, /* system requested, its cell is not GO */ + SVC_GW_R_NOT_VERIFIED_LINK /* link requested, its cell is not GO */ +} svc_gw_reason; + +/* Per OS family, the status of each mechanism. */ +typedef struct svc_gw_table { + svc_gw_status system[SVC_GW_OS_COUNT]; + svc_gw_status link[SVC_GW_OS_COUNT]; +} svc_gw_table; + +typedef struct svc_gw_input { + svc_gw_family family; + svc_gw_mode mode; + int service_running; /* nonzero when the service core holds its pipe */ + svc_gw_client client; + int token_only; /* the mount asks for token-only mode */ +} svc_gw_input; + +typedef struct svc_gw_verdict { + svc_gw_mech mech; + svc_gw_reason reason; +} svc_gw_verdict; + +/* OS family from the major and minor version (RtlGetVersion or the manifest-independent sources). */ +svc_gw_family svc_gw_family_of(DWORD major, DWORD minor); +/* "auto", "system", "link", "attach" (exact, lower case, as in settings.ini). Anything else: SVC_GW_MODE_INVALID. */ +svc_gw_mode svc_gw_mode_parse(const char *s); + +const char *svc_gw_family_name(svc_gw_family f); +const char *svc_gw_mode_name(svc_gw_mode m); +const char *svc_gw_mech_name(svc_gw_mech m); +/* English reason for the log (not localized: logs stay English). */ +const char *svc_gw_reason_name(svc_gw_reason r); +/* IPC error code for a refusal (03 §2.4 vocabulary); "" for SVC_GW_R_OK. */ +const char *svc_gw_reason_code(svc_gw_reason r); + +/* The compiled table: every cell UNVERIFIED (see the notes in svc_gw.c). */ +const svc_gw_table *svc_gw_compiled_table(void); + +void svc_gw_decide_with(const svc_gw_table *t, const svc_gw_input *in, svc_gw_verdict *out); +void svc_gw_decide(const svc_gw_input *in, svc_gw_verdict *out); + +#endif /* SVC_GW_H */ diff --git a/apps/shelldrive-host/svc_gw_attach.c b/apps/shelldrive-host/svc_gw_attach.c new file mode 100644 index 0000000..c21a855 --- /dev/null +++ b/apps/shelldrive-host/svc_gw_attach.c @@ -0,0 +1,143 @@ +/* Per-user attach of a global WebDAV drive (apps/shelldrive-host/svc_gw_attach.h). */ +#include +#include +#include + +#include "svc_gw_attach.h" + +#define GW_TOKEN_LEN 26 + +static const wchar_t k_vista_prefix[] = L"\\\\localhost@"; +static const wchar_t k_vista_mid[] = L"\\DavWWWRoot\\s-"; +static const wchar_t k_xp_prefix[] = L"http://localhost:"; +static const wchar_t k_xp_mid[] = L"/s-"; +static const wchar_t k_xp_provider[] = L"Web Client Network"; + +/* Matches the literal at *p and advances; 0 when it does not match. */ +static int lit(const wchar_t **p, const wchar_t *lit_s) +{ + const wchar_t *q = *p; + size_t i; + for (i = 0; lit_s[i] != 0; i++) { + if (q[i] != lit_s[i]) + return 0; + } + *p = q + i; + return 1; +} + +/* One to five decimal digits, no leading zero, value 1..65535. */ +static int port(const wchar_t **p) +{ + const wchar_t *q = *p; + unsigned long v = 0; + int n = 0; + if (*q == L'0') + return 0; + while (q[n] >= L'0' && q[n] <= L'9') { + if (n >= 5) + return 0; + v = v * 10u + (unsigned long)(q[n] - L'0'); + n++; + } + if (n == 0 || v == 0 || v > 65535u) + return 0; + *p = q + n; + return 1; +} + +/* RFC 4648 base32, lower case: a-z and 2-7. */ +static int token(const wchar_t **p) +{ + const wchar_t *q = *p; + int i; + for (i = 0; i < GW_TOKEN_LEN; i++) { + wchar_t c = q[i]; + if (!((c >= L'a' && c <= L'z') || (c >= L'2' && c <= L'7'))) + return 0; + } + *p = q + GW_TOKEN_LEN; + return 1; +} + +int svc_gw_attach_remote_ok(const wchar_t *remote, int xp_form) +{ + const wchar_t *p = remote; + if (remote == NULL) + return 0; + if (xp_form) { + if (!lit(&p, k_xp_prefix) || !port(&p) || !lit(&p, k_xp_mid) || !token(&p)) + return 0; + } else { + if (!lit(&p, k_vista_prefix) || !port(&p) || !lit(&p, k_vista_mid) || !token(&p)) + return 0; + } + return *p == 0; +} + +static int letter_ok(wchar_t letter) +{ + return letter >= L'A' && letter <= L'Z'; +} + +sd_err svc_gw_attach_map(const svc_gw_attach_ops *ops, wchar_t letter, const wchar_t *remote, int xp_form, + DWORD *win32_error) +{ + DWORD rc; + if (win32_error) + *win32_error = NO_ERROR; + if (ops == NULL || ops->add == NULL || !letter_ok(letter) || !svc_gw_attach_remote_ok(remote, xp_form)) + return SD_E_INVALID_PARAM; + rc = ops->add(ops->ctx, letter, remote, xp_form); + if (win32_error) + *win32_error = rc; + if (rc == NO_ERROR) + return SD_OK; + if (rc == ERROR_ALREADY_ASSIGNED || rc == ERROR_DEVICE_ALREADY_REMEMBERED) + return SD_E_EXISTS; + return SD_E_FAILURE; +} + +sd_err svc_gw_attach_unmap(const svc_gw_attach_ops *ops, wchar_t letter, DWORD *win32_error) +{ + DWORD rc; + if (win32_error) + *win32_error = NO_ERROR; + if (ops == NULL || ops->cancel == NULL || !letter_ok(letter)) + return SD_E_INVALID_PARAM; + rc = ops->cancel(ops->ctx, letter); + if (win32_error) + *win32_error = rc; + return rc == NO_ERROR ? SD_OK : SD_E_FAILURE; +} + +/* ---- real seam (mpr.dll, on the XP floor list: tests/os-imports) ---- */ + +static DWORD real_add(void *ctx, wchar_t letter, const wchar_t *remote, int xp_form) +{ + NETRESOURCEW nr; + wchar_t local[3]; + (void)ctx; + local[0] = letter; + local[1] = L':'; + local[2] = 0; + memset(&nr, 0, sizeof(nr)); + nr.dwType = RESOURCETYPE_DISK; + nr.lpLocalName = local; + nr.lpRemoteName = (LPWSTR)remote; + nr.lpProvider = xp_form ? (LPWSTR)k_xp_provider : NULL; + /* no CONNECT_UPDATE_PROFILE: the mapping is not persistent (02 §7.3) */ + return WNetAddConnection2W(&nr, NULL, NULL, 0); +} + +static DWORD real_cancel(void *ctx, wchar_t letter) +{ + wchar_t local[3]; + (void)ctx; + local[0] = letter; + local[1] = L':'; + local[2] = 0; + return WNetCancelConnection2W(local, 0, TRUE); +} + +const svc_gw_attach_ops svc_gw_attach_real_ops = { NULL, real_add, real_cancel }; diff --git a/apps/shelldrive-host/svc_gw_attach.h b/apps/shelldrive-host/svc_gw_attach.h new file mode 100644 index 0000000..02c27fc --- /dev/null +++ b/apps/shelldrive-host/svc_gw_attach.h @@ -0,0 +1,38 @@ +/* Per-user attach of a global WebDAV drive (02 §7.7 fallback, 03 §1.3 attach mode): the agent of the logged-on user maps + the UNC that the service returns, in that user's own logon session, with that user's token (no credentials). + + The seam (svc_gw_attach_ops) keeps the WNet calls out of the tests; svc_gw_attach_real_ops is the only code that calls + mpr.dll, and the tests never call it. Nothing here is wired to the service or the agent yet: the service method + mount.attach_info for global profiles is not in this build. */ +#ifndef SVC_GW_ATTACH_H +#define SVC_GW_ATTACH_H + +#include + +#include "sd_base/sd_err.h" + +typedef struct svc_gw_attach_ops { + void *ctx; + /* Maps letter (A..Z, colon added by the implementation) to remote. xp_form selects the "Web Client Network" provider + and the http:// form (02 §7.1). Returns a Win32 error (NO_ERROR on success). */ + DWORD (*add)(void *ctx, wchar_t letter, const wchar_t *remote, int xp_form); + /* Cancels the mapping of letter in this logon session (forced, not persistent). Returns a Win32 error. */ + DWORD (*cancel)(void *ctx, wchar_t letter); +} svc_gw_attach_ops; + +/* Pure shape check of the remote name the service returns (02 §7.3, §7.1): + Vista+: \\localhost@PORT\DavWWWRoot\s-<26 base32 lowercase> + XP: http://localhost:PORT/s-<26 base32 lowercase> + PORT is 1..65535 with no leading zero. Anything else (extra segments, other hosts, uppercase) is refused. */ +int svc_gw_attach_remote_ok(const wchar_t *remote, int xp_form); + +/* Maps letter to remote through ops. SD_E_INVALID_PARAM for a bad letter, a bad remote or missing ops; SD_E_EXISTS when + the letter is already in use in this session; SD_E_FAILURE for other errors (*win32_error holds the code). */ +sd_err svc_gw_attach_map(const svc_gw_attach_ops *ops, wchar_t letter, const wchar_t *remote, int xp_form, + DWORD *win32_error); +sd_err svc_gw_attach_unmap(const svc_gw_attach_ops *ops, wchar_t letter, DWORD *win32_error); + +/* The real seam: WNetAddConnection2W / WNetCancelConnection2W without CONNECT_UPDATE_PROFILE (not persistent). */ +extern const svc_gw_attach_ops svc_gw_attach_real_ops; + +#endif /* SVC_GW_ATTACH_H */ diff --git a/apps/shelldrive-host/svc_repair.c b/apps/shelldrive-host/svc_repair.c new file mode 100644 index 0000000..876cb85 --- /dev/null +++ b/apps/shelldrive-host/svc_repair.c @@ -0,0 +1,330 @@ +/* Machine data folder repair (03 §3.2, TM-R34; 04 §6.4). See svc_repair.h. + * + * Rules: a folder is trusted when it is a directory, not a reparse point, and owned by a trusted SID (svc_folder.c, the + * same check the service runs at start). Each check and each security change goes through one handle opened with + * FILE_FLAG_OPEN_REPARSE_POINT, so a link is never followed. The tree below the data folder is scanned without following + * links: a link, a foreign-owned folder or a failed open anywhere in it makes the whole tree untrusted (fail closed). + * The CLI and the service link this file (through shelldrive.exe and shelldrive-host.exe), and so do the unit tests. */ +#include +#include +#include +#include +#include + +#include "svc_folder.h" +#include "svc_repair.h" + +#define REP_VENDOR L"LibreStatic" +#define REP_DATA L"ShellDrive" +#define REP_MAX_DEPTH 24 +#define REP_MAX_ENTRIES 20000u +#define REP_COUNT(a) (sizeof(a) / sizeof((a)[0])) + +typedef struct rep_item { + const wchar_t *rel; + const wchar_t *sddl; +} rep_item; + +/* The 03 §3.2 DACLs. Owner is not set here: a folder gets the default owner of the creating token (Administrators + under UAC, SYSTEM under the service), which the owner rule accepts. */ +static const wchar_t *const k_admin_read = L"D:PAI(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(A;OICI;FR;;;BU)"; + +/* Folders below the data folder, in creation order (a parent before its children). */ +static const rep_item k_dirs[] = { + { L"logs", L"D:PAI(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)" }, + { L"profiles", L"D:PAI(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(A;OICI;FR;;;BU)" }, + { L"service", L"D:PAI(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)" }, + { L"service\\secrets", L"D:PAI(A;OICI;FA;;;SY)" }, + { L"service\\keys", L"D:PAI(A;OICI;FA;;;SY)" }, +}; + +/* Stores that exist only once the program or the installer wrote them. */ +static const rep_item k_files[] = { + { L"known_hosts", L"D:PAI(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(A;OICI;FR;;;BU)" }, + { L"settings.ini", L"D:PAI(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(A;OICI;FR;;;BU)" }, +}; + +enum { REP_MISSING = 0, REP_TRUSTED, REP_UNTRUSTED }; + +/* ---- seam: the shipping owner rule (svc_folder.c) ---- */ + +int svc_repair_real_ops(svc_repair_ops *out) +{ + memset(out, 0, sizeof(*out)); + out->ctx = NULL; + out->owner_trusted = svc_folder_owner_shipping; + out->extra_ace = NULL; + return 0; +} + +/* The DACL text this repair sets: the 03 §3.2 text, plus the seam's extra ACE when the seam has one. */ +static int compose_sddl(const svc_repair_ops *ops, const wchar_t *sddl, wchar_t *out, size_t cch) +{ + if (FAILED(StringCchCopyW(out, cch, sddl))) + return 0; + if (ops->extra_ace != NULL && ops->extra_ace[0] != 0) + return SUCCEEDED(StringCchCatW(out, cch, ops->extra_ace)) ? 1 : 0; + return 1; +} + +static int is_missing(DWORD e) +{ + return e == ERROR_FILE_NOT_FOUND || e == ERROR_PATH_NOT_FOUND; +} + +/* Sets a protected DACL on an open handle (SetSecurityInfo on the handle, never on a path). */ +static DWORD set_dacl_on(HANDLE h, const wchar_t *sddl) +{ + PSECURITY_DESCRIPTOR sd = NULL; + BOOL present = FALSE, defaulted = FALSE; + PACL dacl = NULL; + DWORD e; + if (!ConvertStringSecurityDescriptorToSecurityDescriptorW(sddl, SDDL_REVISION_1, &sd, NULL)) + return GetLastError(); + if (!GetSecurityDescriptorDacl(sd, &present, &dacl, &defaulted)) { + e = GetLastError(); + } else if (!present || dacl == NULL) { + e = ERROR_INVALID_DATA; + } else { + e = SetSecurityInfo(h, SE_FILE_OBJECT, DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION, NULL, + NULL, dacl, NULL); + } + LocalFree(sd); + return e; +} + +/* Checks one entry through a single handle (svc_folder_inspect). want_dir = 1: a folder (owner checked too); 0: a store + file. A link, a wrong kind or an untrusted owner is REP_UNTRUSTED. When sddl is not NULL and the entry is trusted, the + DACL is set on that same handle. A missing entry is REP_MISSING. */ +static DWORD check_entry(const svc_repair_ops *ops, const wchar_t *path, const wchar_t *sddl, int want_dir, int *state) +{ + HANDLE h = NULL; + svc_folder_reason why = SVC_FOLDER_OK; + DWORD e, access; + *state = REP_UNTRUSTED; + access = READ_CONTROL | FILE_READ_ATTRIBUTES | (sddl != NULL ? WRITE_DAC : 0); + e = svc_folder_open(path, access, &h); + if (e != 0) { + if (is_missing(e)) { + *state = REP_MISSING; + return 0; + } + return e; + } + e = svc_folder_inspect(h, want_dir, ops->owner_trusted, ops->ctx, &why); + if (e != 0) { + CloseHandle(h); + return e; + } + if (why == SVC_FOLDER_OK) { + *state = REP_TRUSTED; + if (sddl != NULL) { + wchar_t full[512]; + if (!compose_sddl(ops, sddl, full, REP_COUNT(full))) + e = ERROR_BUFFER_OVERFLOW; + else + e = set_dacl_on(h, full); + } + } + CloseHandle(h); + return e; +} + +/* Scans everything below dir without following links, read only. *clean becomes 0 at the first link, foreign-owned + folder, entry that cannot be opened, or limit. */ +static void scan_tree(const svc_repair_ops *ops, const wchar_t *dir, int depth, DWORD *count, int *clean) +{ + WIN32_FIND_DATAW fd; + HANDLE fh; + wchar_t pattern[MAX_PATH], child[MAX_PATH]; + if (depth > REP_MAX_DEPTH || FAILED(StringCchPrintfW(pattern, REP_COUNT(pattern), L"%s\\*", dir))) { + *clean = 0; + return; + } + fh = FindFirstFileW(pattern, &fd); + if (fh == INVALID_HANDLE_VALUE) { + if (GetLastError() != ERROR_FILE_NOT_FOUND) + *clean = 0; + return; + } + do { + int state = REP_UNTRUSTED; + if (lstrcmpW(fd.cFileName, L".") == 0 || lstrcmpW(fd.cFileName, L"..") == 0) + continue; + if (++(*count) > REP_MAX_ENTRIES || FAILED(StringCchPrintfW(child, REP_COUNT(child), L"%s\\%s", dir, fd.cFileName))) { + *clean = 0; + break; + } + if (fd.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) { + *clean = 0; + break; + } + if (!(fd.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY)) + continue; + if (check_entry(ops, child, NULL, 1, &state) != 0 || state != REP_TRUSTED) { + *clean = 0; + break; + } + scan_tree(ops, child, depth + 1, count, clean); + if (!*clean) + break; + } while (FindNextFileW(fh, &fd)); + FindClose(fh); +} + +/* Renames an untrusted entry to the first free .untrusted-. A link is renamed itself, never followed. */ +static DWORD move_aside(const wchar_t *path, wchar_t *aside, size_t cch) +{ + unsigned n; + for (n = 1; n < 10000u; n++) { + if (FAILED(StringCchPrintfW(aside, cch, L"%s.untrusted-%u", path, n))) + return ERROR_BUFFER_OVERFLOW; + if (GetFileAttributesW(aside) == INVALID_FILE_ATTRIBUTES) + break; + } + if (n >= 10000u) + return ERROR_ALREADY_EXISTS; + return MoveFileExW(path, aside, 0) ? 0 : GetLastError(); +} + +static DWORD create_folder(const svc_repair_ops *ops, const wchar_t *path, const wchar_t *sddl) +{ + SECURITY_ATTRIBUTES sa; + PSECURITY_DESCRIPTOR sd = NULL; + wchar_t full[512]; + DWORD e = 0; + if (!compose_sddl(ops, sddl, full, REP_COUNT(full))) + return ERROR_BUFFER_OVERFLOW; + if (!ConvertStringSecurityDescriptorToSecurityDescriptorW(full, SDDL_REVISION_1, &sd, NULL)) + return GetLastError(); + memset(&sa, 0, sizeof(sa)); + sa.nLength = sizeof(sa); + sa.lpSecurityDescriptor = sd; + if (!CreateDirectoryW(path, &sa)) + e = GetLastError(); + LocalFree(sd); + return e; +} + +/* Brings a top folder (vendor or data) to its DACL. An untrusted one is moved aside; then a missing one is created. + scan = 1 also checks the tree below it first. */ +static DWORD ensure_top(const svc_repair_ops *ops, const wchar_t *path, int scan, svc_repair_result *out) +{ + int state = REP_UNTRUSTED; + DWORD e; + wchar_t aside[MAX_PATH]; + e = check_entry(ops, path, k_admin_read, 1, &state); + if (e != 0) + return e; + if (state == REP_TRUSTED && scan) { + DWORD count = 0; + int clean = 1; + scan_tree(ops, path, 0, &count, &clean); + if (!clean) + state = REP_UNTRUSTED; + } + if (state == REP_UNTRUSTED) { + e = move_aside(path, aside, MAX_PATH); + if (e != 0) + return e; + out->moved = 1; + (void)StringCchCopyW(out->path, REP_COUNT(out->path), aside); + state = REP_MISSING; + } + if (state == REP_MISSING) { + e = create_folder(ops, path, k_admin_read); + if (e != 0) + return e; + } + out->folders++; + return 0; +} + +DWORD svc_repair_data(const svc_repair_ops *ops, const wchar_t *base, svc_repair_result *out) +{ + wchar_t root[MAX_PATH], vendor[MAX_PATH], data[MAX_PATH], path[MAX_PATH]; + HANDLE h = NULL; + svc_folder_reason why = SVC_FOLDER_OK; + size_t i, n; + DWORD e; + int state; + memset(out, 0, sizeof(*out)); + if (base == NULL || base[0] == 0) + return ERROR_PATH_NOT_FOUND; + if (FAILED(StringCchCopyW(root, REP_COUNT(root), base))) + return ERROR_BUFFER_OVERFLOW; + n = lstrlenW(root); + while (n > 0 && root[n - 1] == L'\\') + root[--n] = 0; + + /* The base folder itself (%ProgramData%) is never renamed: a link there refuses the whole repair. */ + e = svc_folder_open(root, FILE_READ_ATTRIBUTES, &h); + if (e != 0) + return e; + e = svc_folder_inspect(h, 1, NULL, NULL, &why); + CloseHandle(h); + if (e != 0) + return e; + if (why == SVC_FOLDER_REPARSE) { + out->refused = 1; + (void)StringCchCopyW(out->path, REP_COUNT(out->path), root); + return ERROR_ACCESS_DENIED; + } + if (why == SVC_FOLDER_KIND) + return ERROR_DIRECTORY; + + if (FAILED(StringCchPrintfW(vendor, REP_COUNT(vendor), L"%s\\%s", root, REP_VENDOR)) || + FAILED(StringCchPrintfW(data, REP_COUNT(data), L"%s\\%s", vendor, REP_DATA))) + return ERROR_BUFFER_OVERFLOW; + + /* 1. the vendor folder (D45): its own DACL only; what else it holds belongs to other LibreStatic products. */ + e = ensure_top(ops, vendor, 0, out); + if (e != 0) + return e; + + /* 2. the data folder with its whole tree. If the vendor folder was just moved aside, the data folder went with it + and is created fresh here. */ + e = ensure_top(ops, data, 1, out); + if (e != 0) + return e; + + /* 3. the folders below the data folder, each through its own handle. A link that appeared since the scan refuses + the repair, and nothing below it is followed. */ + for (i = 0; i < REP_COUNT(k_dirs); i++) { + if (FAILED(StringCchPrintfW(path, REP_COUNT(path), L"%s\\%s", data, k_dirs[i].rel))) + return ERROR_BUFFER_OVERFLOW; + state = REP_UNTRUSTED; + e = check_entry(ops, path, k_dirs[i].sddl, 1, &state); + if (e != 0) + return e; + if (state == REP_UNTRUSTED) { + (void)StringCchCopyW(out->path, REP_COUNT(out->path), path); + out->refused = 1; + return ERROR_ACCESS_DENIED; + } + if (state == REP_MISSING) { + e = create_folder(ops, path, k_dirs[i].sddl); + if (e != 0) + return e; + } + out->folders++; + } + + /* 4. the store files that exist. */ + for (i = 0; i < REP_COUNT(k_files); i++) { + if (FAILED(StringCchPrintfW(path, REP_COUNT(path), L"%s\\%s", data, k_files[i].rel))) + return ERROR_BUFFER_OVERFLOW; + state = REP_UNTRUSTED; + e = check_entry(ops, path, k_files[i].sddl, 0, &state); + if (e != 0) + return e; + if (state == REP_UNTRUSTED) { + (void)StringCchCopyW(out->path, REP_COUNT(out->path), path); + out->refused = 1; + return ERROR_ACCESS_DENIED; + } + if (state == REP_TRUSTED) + out->folders++; + } + return 0; +} diff --git a/apps/shelldrive-host/svc_repair.h b/apps/shelldrive-host/svc_repair.h new file mode 100644 index 0000000..eb78dcf --- /dev/null +++ b/apps/shelldrive-host/svc_repair.h @@ -0,0 +1,41 @@ +/* Machine data folder repair: shelldrive service repair-data (03 §3.2, §6.1, TM-R34, T-63; 04 §6.4; M5). + * + * Checks %ProgramData%\LibreStatic and %ProgramData%\LibreStatic\ShellDrive (and the stores under it) and sets them + * back to the 3.2 DACLs. A folder that is a reparse point (link or junction), has a foreign owner, or holds a reparse + * point or a foreign-owned folder anywhere below it is moved aside (LibreStatic.untrusted- or + * ShellDrive.untrusted-), never imported, and a fresh folder is created in its place. A link is renamed, never + * followed. Every check and every SetSecurityInfo runs on a handle opened with FILE_FLAG_OPEN_REPARSE_POINT. + * + * The base folder (%ProgramData%) is taken as a parameter so the unit tests run on a temp directory; the CLI passes + * CSIDL_COMMON_APPDATA. The caller checks elevation (the CLI exits 9 before it gets here). */ +#ifndef SVC_REPAIR_H +#define SVC_REPAIR_H + +#include + +typedef struct svc_repair_ops { + void *ctx; + /* 1 when a folder with this owner is trusted. The shipping build trusts SYSTEM and BUILTIN\Administrators; the tests + trust every owner or none. */ + int (*owner_trusted)(void *ctx, PSID owner); + /* SDDL text appended to every DACL this repair sets (NULL in the shipping build). The tests pass "(A;OICI;FA;;;OW)": + a non-elevated test process is only the owner of the folders it creates, and the owner needs the same rights as + BUILTIN\Administrators to build and remove the tree. */ + const wchar_t *extra_ace; +} svc_repair_ops; + +typedef struct svc_repair_result { + int refused; /* the base folder is a link: nothing was changed; path names it */ + int moved; /* an untrusted folder was moved aside; path is its new name */ + wchar_t path[MAX_PATH]; + DWORD folders; /* folders and stores checked, set or created */ +} svc_repair_result; + +/* Fills the seam with the shipping owner rule. Returns 0. */ +int svc_repair_real_ops(svc_repair_ops *out); + +/* Repairs the tree under base. Returns 0, or a Win32 error (a refused base gives ERROR_ACCESS_DENIED with + out->refused set; a missing base gives ERROR_PATH_NOT_FOUND). */ +DWORD svc_repair_data(const svc_repair_ops *ops, const wchar_t *base, svc_repair_result *out); + +#endif /* SVC_REPAIR_H */ diff --git a/apps/shelldrive-host/svc_scm.c b/apps/shelldrive-host/svc_scm.c new file mode 100644 index 0000000..31f1391 --- /dev/null +++ b/apps/shelldrive-host/svc_scm.c @@ -0,0 +1,503 @@ +/* ShellDriveSvc registration (03 §1.3, §6.1, 10.3; M5 part 2). See svc_scm.h. + * + * Only the CLI links this file (through shelldrive.exe) and the unit tests. Every rule that decides what is called, + * in which order, and what is rolled back is in the svc_scm_* functions, which only see the seam. The advapi32 + * calls are in the scm_* functions of svc_scm_real_ops, the only place that touches the Service Control Manager or + * HKLM. All import names here are on the XP SP2/SP3 floor lists (tests/os-imports). */ +#include +#include +#include + +#include "sd_platform/sd_platform.h" +#include "svc_scm.h" + +#define SVC_SCM_SERVICE_NAME L"ShellDriveSvc" +#define SVC_SCM_HOST_BINARY L"shelldrive-host.exe" +#define SVC_SCM_EVENTLOG_APP L"SYSTEM\\CurrentControlSet\\Services\\EventLog\\Application" +#define SVC_SCM_DEPS L"Tcpip\0" /* multi-string: "Tcpip", then the empty terminator */ +#define SVC_SCM_START_WAIT_MS 30000u +#define SVC_SCM_STOP_WAIT_MS 60000u +#define SVC_SCM_POLL_MS 250u +#define SVC_SCM_RESET_PERIOD_S 86400u /* one day */ +#define SVC_SCM_COUNT(a) (sizeof(a) / sizeof((a)[0])) + +/* SDK 7.1A headers may lack these (Vista additions): values from winsvc.h. */ +#ifndef SERVICE_CONFIG_SERVICE_SID_INFO +#define SERVICE_CONFIG_SERVICE_SID_INFO 5 +#endif +#ifndef SERVICE_SID_TYPE_UNRESTRICTED +#define SERVICE_SID_TYPE_UNRESTRICTED 0x00000001u +#endif + +typedef struct svc_sid_info_s { + DWORD type; /* same layout as SERVICE_SID_INFO */ +} svc_sid_info_s; + +/* ---- rules (seam only) ---- */ + +DWORD svc_scm_install(const svc_scm_ops *ops, const svc_install_spec *s) +{ + DWORD e; + if (!ops->is_elevated(ops->ctx)) + return ERROR_ACCESS_DENIED; + e = ops->create_service(ops->ctx, s); + if (e != 0) + return e; /* nothing of ours exists yet (ERROR_SERVICE_EXISTS and friends) */ + e = ops->set_description(ops->ctx, s); + if (e == 0) + e = ops->set_failure_actions(ops->ctx, s); + if (e == 0 && s->sid_unrestricted && ops->is_vista_or_later(ops->ctx)) + e = ops->set_sid_type(ops->ctx, SERVICE_SID_TYPE_UNRESTRICTED); + if (e == 0) + e = ops->register_source(ops->ctx, SVC_SCM_EVENT_SOURCE, s->message_file, SVC_SCM_EVENT_TYPES); + if (e != 0) + (void)ops->delete_service(ops->ctx); /* roll back: a half-configured service must not stay installed */ + return e; +} + +DWORD svc_scm_uninstall(const svc_scm_ops *ops) +{ + DWORD e; + if (!ops->is_elevated(ops->ctx)) + return ERROR_ACCESS_DENIED; + (void)ops->stop_service(ops->ctx); /* a service that is not running is fine */ + e = ops->delete_service(ops->ctx); + if (e != 0) + return e; + e = ops->unregister_source(ops->ctx, SVC_SCM_EVENT_SOURCE); + return e == ERROR_FILE_NOT_FOUND ? 0 : e; +} + +DWORD svc_scm_start(const svc_scm_ops *ops) +{ + if (!ops->is_elevated(ops->ctx)) + return ERROR_ACCESS_DENIED; + return ops->start_service(ops->ctx); +} + +DWORD svc_scm_stop(const svc_scm_ops *ops) +{ + if (!ops->is_elevated(ops->ctx)) + return ERROR_ACCESS_DENIED; + return ops->stop_service(ops->ctx); +} + +int svc_path_check_of(const wchar_t *image, const svc_install_spec *expect) +{ + if (expect == NULL || image == NULL) + return SVC_PATH_UNKNOWN; + if (image[0] != L'"') + return SVC_PATH_UNQUOTED; + if (lstrcmpiW(image, expect->command) == 0) + return SVC_PATH_OK; + return SVC_PATH_OTHER; +} + +DWORD svc_scm_status(const svc_scm_ops *ops, const svc_install_spec *expect, svc_status_report *out) +{ + DWORD e; + memset(out, 0, sizeof(*out)); + e = ops->query_service(ops->ctx, out); + if (e != 0) + return e; + out->binary_check = out->installed ? svc_path_check_of(out->binary, expect) : SVC_PATH_UNKNOWN; + out->message_file[0] = 0; + e = ops->query_source(ops->ctx, SVC_SCM_EVENT_SOURCE, out->message_file, SVC_SCM_COUNT(out->message_file)); + if (e == ERROR_FILE_NOT_FOUND) { + out->message_file[0] = 0; + out->source_check = SVC_SRC_MISSING; + return 0; + } + if (e != 0) + return e; + if (expect == NULL) + out->source_check = SVC_SRC_UNKNOWN; + else if (lstrcmpiW(out->message_file, expect->message_file) == 0) + out->source_check = SVC_SRC_OK; + else + out->source_check = SVC_SRC_OTHER; + return 0; +} + +DWORD svc_scm_host_spec(svc_install_spec *s, const wchar_t *description) +{ + wchar_t exe[MAX_PATH]; + DWORD n, i, cut = 0; + DWORD attr; + n = GetModuleFileNameW(NULL, exe, MAX_PATH); + if (n == 0 || n >= MAX_PATH) + return ERROR_BUFFER_OVERFLOW; + for (i = 0; i < n; i++) { + if (exe[i] == L'\\') + cut = i + 1; /* keep the trailing backslash */ + } + if (cut == 0) + return ERROR_PATH_NOT_FOUND; + exe[cut] = 0; + if (FAILED(StringCchCatW(exe, MAX_PATH, SVC_SCM_HOST_BINARY))) + return ERROR_BUFFER_OVERFLOW; + attr = GetFileAttributesW(exe); + if (attr == INVALID_FILE_ATTRIBUTES) + return ERROR_FILE_NOT_FOUND; + memset(s, 0, sizeof(*s)); + if (FAILED(StringCchCopyW(s->message_file, MAX_PATH, exe))) + return ERROR_BUFFER_OVERFLOW; + if (FAILED(StringCchPrintfW(s->command, SVC_SCM_COUNT(s->command), L"\"%s\" service", exe))) + return ERROR_BUFFER_OVERFLOW; + if (FAILED(StringCchCopyW(s->description, SVC_SCM_COUNT(s->description), description ? description : L""))) + return ERROR_BUFFER_OVERFLOW; + s->restart_ms[0] = 5000u; /* 1.3: restart after 5 s, 30 s, then every 5 min */ + s->restart_ms[1] = 30000u; + s->restart_ms[2] = 300000u; + s->reset_period_s = SVC_SCM_RESET_PERIOD_S; + s->sid_unrestricted = 1; + return 0; +} + +/* ---- Win32 seam (advapi32, XP+) ---- */ + +static DWORD open_service(DWORD manager_access, DWORD svc_access, SC_HANDLE *scm, SC_HANDLE *svc) +{ + DWORD e; + *scm = OpenSCManagerW(NULL, NULL, manager_access); + if (*scm == NULL) + return GetLastError(); + *svc = OpenServiceW(*scm, SVC_SCM_SERVICE_NAME, svc_access); + if (*svc == NULL) { + e = GetLastError(); + CloseServiceHandle(*scm); + *scm = NULL; + return e; + } + return 0; +} + +static void close_handles(SC_HANDLE scm, SC_HANDLE svc) +{ + if (svc != NULL) + CloseServiceHandle(svc); + if (scm != NULL) + CloseServiceHandle(scm); +} + +static DWORD scm_create_service(void *ctx, const svc_install_spec *s) +{ + SC_HANDLE scm, svc; + (void)ctx; + scm = OpenSCManagerW(NULL, NULL, SC_MANAGER_CREATE_SERVICE); + if (scm == NULL) + return GetLastError(); + /* lpServiceStartName NULL = LocalSystem (1.3). */ + svc = CreateServiceW(scm, SVC_SCM_SERVICE_NAME, SVC_SCM_DISPLAY_NAME, SERVICE_ALL_ACCESS, + SERVICE_WIN32_OWN_PROCESS, SERVICE_AUTO_START, SERVICE_ERROR_NORMAL, s->command, + NULL, NULL, SVC_SCM_DEPS, NULL, NULL); + if (svc == NULL) { + DWORD e = GetLastError(); + CloseServiceHandle(scm); + return e; + } + close_handles(scm, svc); + return 0; +} + +static DWORD scm_set_description(void *ctx, const svc_install_spec *s) +{ + SC_HANDLE scm = NULL, svc = NULL; + SERVICE_DESCRIPTIONW d; + DWORD e; + (void)ctx; + e = open_service(SC_MANAGER_CONNECT, SERVICE_CHANGE_CONFIG, &scm, &svc); + if (e != 0) + return e; + d.lpDescription = (LPWSTR)s->description; + if (!ChangeServiceConfig2W(svc, SERVICE_CONFIG_DESCRIPTION, &d)) + e = GetLastError(); + close_handles(scm, svc); + return e; +} + +static DWORD scm_set_failure_actions(void *ctx, const svc_install_spec *s) +{ + SC_HANDLE scm = NULL, svc = NULL; + SC_ACTION acts[SVC_SCM_RESTART_COUNT]; + SERVICE_FAILURE_ACTIONSW fa; + DWORD i, e; + (void)ctx; + for (i = 0; i < SVC_SCM_RESTART_COUNT; i++) { + acts[i].Type = SC_ACTION_RESTART; + acts[i].Delay = s->restart_ms[i]; + } + memset(&fa, 0, sizeof(fa)); + fa.dwResetPeriod = s->reset_period_s; + fa.cActions = SVC_SCM_RESTART_COUNT; + fa.lpsaActions = acts; + e = open_service(SC_MANAGER_CONNECT, SERVICE_CHANGE_CONFIG, &scm, &svc); + if (e != 0) + return e; + if (!ChangeServiceConfig2W(svc, SERVICE_CONFIG_FAILURE_ACTIONS, &fa)) + e = GetLastError(); + close_handles(scm, svc); + return e; +} + +static DWORD scm_set_sid_type(void *ctx, DWORD sid_type) +{ + SC_HANDLE scm = NULL, svc = NULL; + svc_sid_info_s info; + DWORD e; + (void)ctx; + info.type = sid_type; + e = open_service(SC_MANAGER_CONNECT, SERVICE_CHANGE_CONFIG, &scm, &svc); + if (e != 0) + return e; + if (!ChangeServiceConfig2W(svc, SERVICE_CONFIG_SERVICE_SID_INFO, &info)) + e = GetLastError(); + close_handles(scm, svc); + return e; +} + +static DWORD scm_delete_service(void *ctx) +{ + SC_HANDLE scm = NULL, svc = NULL; + DWORD e; + (void)ctx; + e = open_service(SC_MANAGER_CONNECT, DELETE, &scm, &svc); + if (e != 0) + return e; + if (!DeleteService(svc)) + e = GetLastError(); + close_handles(scm, svc); + return e; +} + +static DWORD scm_start_service(void *ctx) +{ + SC_HANDLE scm = NULL, svc = NULL; + SERVICE_STATUS st; + DWORD waited = 0, e; + (void)ctx; + e = open_service(SC_MANAGER_CONNECT, SERVICE_START | SERVICE_QUERY_STATUS, &scm, &svc); + if (e != 0) + return e; + if (!StartServiceW(svc, 0, NULL)) { + e = GetLastError(); + close_handles(scm, svc); + return e; + } + for (;;) { + if (!QueryServiceStatus(svc, &st)) { + e = GetLastError(); + break; + } + if (st.dwCurrentState == SERVICE_RUNNING) { + e = 0; + break; + } + if (st.dwCurrentState == SERVICE_STOPPED) { + e = st.dwWin32ExitCode != 0 ? st.dwWin32ExitCode : ERROR_SERVICE_NEVER_STARTED; + break; + } + if (waited >= SVC_SCM_START_WAIT_MS) { + e = ERROR_SERVICE_REQUEST_TIMEOUT; + break; + } + Sleep(SVC_SCM_POLL_MS); + waited += SVC_SCM_POLL_MS; + } + close_handles(scm, svc); + return e; +} + +static DWORD scm_stop_service(void *ctx) +{ + SC_HANDLE scm = NULL, svc = NULL; + SERVICE_STATUS st; + DWORD waited = 0, e; + (void)ctx; + e = open_service(SC_MANAGER_CONNECT, SERVICE_STOP | SERVICE_QUERY_STATUS, &scm, &svc); + if (e != 0) + return e; + if (!QueryServiceStatus(svc, &st)) { + e = GetLastError(); + close_handles(scm, svc); + return e; + } + if (st.dwCurrentState == SERVICE_STOPPED) { + close_handles(scm, svc); + return ERROR_SERVICE_NOT_ACTIVE; + } + if (!ControlService(svc, SERVICE_CONTROL_STOP, &st)) { + e = GetLastError(); + close_handles(scm, svc); + return e; + } + for (;;) { + if (!QueryServiceStatus(svc, &st)) { + e = GetLastError(); + break; + } + if (st.dwCurrentState == SERVICE_STOPPED) { + e = 0; + break; + } + if (waited >= SVC_SCM_STOP_WAIT_MS) { + e = ERROR_SERVICE_REQUEST_TIMEOUT; + break; + } + Sleep(SVC_SCM_POLL_MS); + waited += SVC_SCM_POLL_MS; + } + close_handles(scm, svc); + return e; +} + +static DWORD scm_register_source(void *ctx, const wchar_t *source, const wchar_t *message_file, DWORD types) +{ + HKEY key = NULL; + wchar_t path[256]; + DWORD e; + (void)ctx; + if (FAILED(StringCchPrintfW(path, SVC_SCM_COUNT(path), L"%s\\%s", SVC_SCM_EVENTLOG_APP, source))) + return ERROR_BUFFER_OVERFLOW; + e = RegCreateKeyExW(HKEY_LOCAL_MACHINE, path, 0, NULL, REG_OPTION_NON_VOLATILE, KEY_WRITE, NULL, &key, NULL); + if (e != ERROR_SUCCESS) + return e; + e = RegSetValueExW(key, L"EventMessageFile", 0, REG_EXPAND_SZ, (const BYTE *)message_file, + (DWORD)((lstrlenW(message_file) + 1) * sizeof(wchar_t))); + if (e == ERROR_SUCCESS) + e = RegSetValueExW(key, L"TypesSupported", 0, REG_DWORD, (const BYTE *)&types, sizeof(types)); + RegCloseKey(key); + return e; +} + +static DWORD scm_unregister_source(void *ctx, const wchar_t *source) +{ + wchar_t path[256]; + (void)ctx; + if (FAILED(StringCchPrintfW(path, SVC_SCM_COUNT(path), L"%s\\%s", SVC_SCM_EVENTLOG_APP, source))) + return ERROR_BUFFER_OVERFLOW; + return (DWORD)RegDeleteKeyW(HKEY_LOCAL_MACHINE, path); +} + +/* TokenElevation (20) is Vista+; on XP the query fails and the SCM decides (access denied for non-admins). */ +static int scm_is_elevated(void *ctx) +{ + HANDLE tok = NULL; + DWORD elevated = 0, len = 0; + int r = 1; + (void)ctx; + if (!OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &tok)) + return 1; + if (GetTokenInformation(tok, (TOKEN_INFORMATION_CLASS)20, &elevated, sizeof(elevated), &len)) + r = elevated ? 1 : 0; + CloseHandle(tok); + return r; +} + +static int scm_is_vista_or_later(void *ctx) +{ + (void)ctx; + return sd_os_at_least(6, 0, 0); +} + +/* Read-only: OpenSCManager with SC_MANAGER_CONNECT and OpenService with the query rights, which the default service + security descriptor grants to every interactive user. A missing service is reported through out->installed. */ +static DWORD scm_query_service(void *ctx, svc_status_report *out) +{ + SC_HANDLE scm = NULL, svc = NULL; + SERVICE_STATUS st; + QUERY_SERVICE_CONFIGW *cfg = NULL; + DWORD e = 0, need = 0; + BOOL ok; + (void)ctx; + out->installed = 0; + scm = OpenSCManagerW(NULL, NULL, SC_MANAGER_CONNECT); + if (scm == NULL) + return GetLastError(); + svc = OpenServiceW(scm, SVC_SCM_SERVICE_NAME, SERVICE_QUERY_CONFIG | SERVICE_QUERY_STATUS); + if (svc == NULL) { + e = GetLastError(); + CloseServiceHandle(scm); + return e == ERROR_SERVICE_DOES_NOT_EXIST ? 0 : e; + } + out->installed = 1; + if (!QueryServiceStatus(svc, &st)) { + e = GetLastError(); + } else { + out->state = st.dwCurrentState; + ok = QueryServiceConfigW(svc, NULL, 0, &need); + if (!ok && GetLastError() == ERROR_INSUFFICIENT_BUFFER && need > 0) { + cfg = (QUERY_SERVICE_CONFIGW *)LocalAlloc(LMEM_FIXED, need); + if (cfg == NULL) + e = ERROR_NOT_ENOUGH_MEMORY; + else if (!QueryServiceConfigW(svc, cfg, need, &need)) + e = GetLastError(); + else { + out->start_type = cfg->dwStartType; + if (cfg->lpBinaryPathName != NULL) + (void)StringCchCopyW(out->binary, SVC_SCM_COUNT(out->binary), cfg->lpBinaryPathName); + } + } else { + e = ok ? ERROR_INVALID_DATA : GetLastError(); + } + } + if (cfg != NULL) + LocalFree(cfg); + close_handles(scm, svc); + return e; +} + +/* Read-only: HKLM\...\EventLog\Application\\EventMessageFile. A missing source is ERROR_FILE_NOT_FOUND; a source + without a message file value gives an empty string (reported as another program's). */ +static DWORD scm_query_source(void *ctx, const wchar_t *source, wchar_t *message_file, size_t cch) +{ + HKEY key = NULL; + wchar_t path[256]; + DWORD e, type = 0, bytes; + (void)ctx; + if (message_file == NULL || cch == 0) + return ERROR_INSUFFICIENT_BUFFER; + message_file[0] = 0; + if (FAILED(StringCchPrintfW(path, SVC_SCM_COUNT(path), L"%s\\%s", SVC_SCM_EVENTLOG_APP, source))) + return ERROR_BUFFER_OVERFLOW; + e = RegOpenKeyExW(HKEY_LOCAL_MACHINE, path, 0, KEY_READ, &key); + if (e != ERROR_SUCCESS) + return e; + bytes = (DWORD)(cch * sizeof(wchar_t)); + e = RegQueryValueExW(key, L"EventMessageFile", NULL, &type, (LPBYTE)message_file, &bytes); + RegCloseKey(key); + if (e == ERROR_FILE_NOT_FOUND || e == ERROR_MORE_DATA) { + message_file[0] = 0; + return ERROR_SUCCESS; + } + if (e != ERROR_SUCCESS) + return e; + if (type != REG_SZ && type != REG_EXPAND_SZ) { + message_file[0] = 0; + return ERROR_SUCCESS; + } + if (bytes / sizeof(wchar_t) < cch) + message_file[bytes / sizeof(wchar_t)] = 0; + message_file[cch - 1] = 0; + return ERROR_SUCCESS; +} + +int svc_scm_real_ops(svc_scm_ops *out) +{ + memset(out, 0, sizeof(*out)); + out->ctx = NULL; + out->create_service = scm_create_service; + out->set_description = scm_set_description; + out->set_failure_actions = scm_set_failure_actions; + out->set_sid_type = scm_set_sid_type; + out->delete_service = scm_delete_service; + out->start_service = scm_start_service; + out->stop_service = scm_stop_service; + out->register_source = scm_register_source; + out->unregister_source = scm_unregister_source; + out->is_elevated = scm_is_elevated; + out->is_vista_or_later = scm_is_vista_or_later; + out->query_service = scm_query_service; + out->query_source = scm_query_source; + return 0; +} diff --git a/apps/shelldrive-host/svc_scm.h b/apps/shelldrive-host/svc_scm.h new file mode 100644 index 0000000..b7d80ef --- /dev/null +++ b/apps/shelldrive-host/svc_scm.h @@ -0,0 +1,86 @@ +/* ShellDriveSvc registration: shelldrive service install | uninstall | start | stop (03 §1.3, §6.1, 10.3; M5). + * + * The rules (elevation check, order of the SCM calls, rollback) live in svc_scm.c as plain functions over a + * function-pointer seam (svc_scm_ops). svc_scm_real_ops fills the seam with the advapi32 calls; the unit tests + * (tests/unit/sd_svc) fill it with a fake, so they never create, start, stop or delete a real service and never + * write the Event Log source. Only the CLI calls these functions; the service itself does not register anything. */ +#ifndef SVC_SCM_H +#define SVC_SCM_H + +#include + +#define SVC_SCM_DISPLAY_NAME L"ShellDrive Service" +#define SVC_SCM_EVENT_SOURCE L"ShellDrive" +#define SVC_SCM_EVENT_TYPES 7u /* TypesSupported: information, warning, error (10.3) */ +#define SVC_SCM_RESTART_COUNT 3u + +typedef struct svc_install_spec { + wchar_t command[MAX_PATH * 2]; /* "\shelldrive-host.exe" service (quoted, the binary path) */ + wchar_t message_file[MAX_PATH]; /* "\shelldrive-host.exe": the event message file */ + wchar_t description[256]; /* localized, ChangeServiceConfig2W(SERVICE_CONFIG_DESCRIPTION) */ + DWORD restart_ms[SVC_SCM_RESTART_COUNT]; /* failure actions: restart after these delays (1.3) */ + DWORD reset_period_s; /* the failure count resets after this many seconds */ + int sid_unrestricted; /* 1 = SERVICE_SID_TYPE_UNRESTRICTED where the OS has it (Vista+) */ +} svc_install_spec; + +/* What svc_scm_status reports (03 §6.1, "service status"). */ +enum { SVC_PATH_UNKNOWN = 0, SVC_PATH_OK, SVC_PATH_UNQUOTED, SVC_PATH_OTHER }; +enum { SVC_SRC_UNKNOWN = 0, SVC_SRC_OK, SVC_SRC_MISSING, SVC_SRC_OTHER }; + +typedef struct svc_status_report { + int installed; /* 0 when ShellDriveSvc does not exist */ + DWORD state; /* SERVICE_RUNNING and friends; 0 when not installed */ + DWORD start_type; /* SERVICE_AUTO_START and friends; 0 when not installed */ + wchar_t binary[MAX_PATH * 2]; /* the registered ImagePath, as stored */ + int binary_check; /* SVC_PATH_* */ + int source_check; /* SVC_SRC_* */ + wchar_t message_file[MAX_PATH]; /* EventMessageFile of the Event Log source, when registered */ +} svc_status_report; + +typedef struct svc_scm_ops { + void *ctx; + /* CreateServiceW: LocalSystem, SERVICE_WIN32_OWN_PROCESS, SERVICE_AUTO_START, Tcpip dependency. */ + DWORD (*create_service)(void *ctx, const svc_install_spec *s); + DWORD (*set_description)(void *ctx, const svc_install_spec *s); + DWORD (*set_failure_actions)(void *ctx, const svc_install_spec *s); + DWORD (*set_sid_type)(void *ctx, DWORD sid_type); + DWORD (*delete_service)(void *ctx); + /* Returns after the service reports SERVICE_RUNNING (or the wait gives up). */ + DWORD (*start_service)(void *ctx); + /* Returns after the service reports SERVICE_STOPPED (or the wait gives up). */ + DWORD (*stop_service)(void *ctx); + DWORD (*register_source)(void *ctx, const wchar_t *source, const wchar_t *message_file, DWORD types); + DWORD (*unregister_source)(void *ctx, const wchar_t *source); + int (*is_elevated)(void *ctx); /* 1 = elevated (or unknown on XP: the SCM decides) */ + int (*is_vista_or_later)(void *ctx); + /* Read-only queries (no elevation needed). query_service fills installed, state, start_type and binary; a missing + service is not an error (installed = 0, returns 0). query_source returns ERROR_FILE_NOT_FOUND when the source is + not registered, else copies its EventMessageFile into message_file. */ + DWORD (*query_service)(void *ctx, svc_status_report *out); + DWORD (*query_source)(void *ctx, const wchar_t *source, wchar_t *message_file, size_t cch); +} svc_scm_ops; + +/* Fills the seam with the advapi32 calls. Returns 0. */ +int svc_scm_real_ops(svc_scm_ops *out); + +/* Builds the spec for this build from the directory of the running program (shelldrive.exe and shelldrive-host.exe + are installed side by side). description is the localized text. Returns ERROR_FILE_NOT_FOUND when the host is + missing. */ +DWORD svc_scm_host_spec(svc_install_spec *s, const wchar_t *description); + +/* Each returns a Win32 error code, 0 on success. Non-elevated callers get ERROR_ACCESS_DENIED before any call. */ +DWORD svc_scm_install(const svc_scm_ops *ops, const svc_install_spec *s); +DWORD svc_scm_uninstall(const svc_scm_ops *ops); +DWORD svc_scm_start(const svc_scm_ops *ops); +DWORD svc_scm_stop(const svc_scm_ops *ops); + +/* Reports the service and its Event Log source; needs no elevation. expect is the spec of this install + (svc_scm_host_spec), or NULL when the host program is not next to the caller: the checks then say UNKNOWN. + Returns a Win32 error only when a query itself fails. */ +DWORD svc_scm_status(const svc_scm_ops *ops, const svc_install_spec *expect, svc_status_report *out); + +/* The binary path check on its own: OK only when image equals expect->command (case-insensitive: quoted, this + install's program, " service"); UNQUOTED when image does not start with a quote; OTHER otherwise. */ +int svc_path_check_of(const wchar_t *image, const svc_install_spec *expect); + +#endif /* SVC_SCM_H */ diff --git a/apps/shelldrive-host/svc_status.c b/apps/shelldrive-host/svc_status.c new file mode 100644 index 0000000..2a40657 --- /dev/null +++ b/apps/shelldrive-host/svc_status.c @@ -0,0 +1,146 @@ +/* Service status report: see svc_status.h. Pure. */ +#include +#include + +#include "svc_status.h" + +static void utf8_of(const wchar_t *w, char *out, size_t cch) +{ + out[0] = 0; + if (w && w[0]) + WideCharToMultiByte(CP_UTF8, 0, w, -1, out, (int)cch, NULL, NULL); + out[cch - 1] = 0; +} + +const char *svc_status_state_key(DWORD st) +{ + switch (st) { + case SERVICE_RUNNING: return "running"; + case SERVICE_STOPPED: return "stopped"; + case SERVICE_START_PENDING: return "starting"; + case SERVICE_STOP_PENDING: return "stopping"; + case SERVICE_PAUSED: return "paused"; + default: return "unknown"; + } +} + +const char *svc_status_start_type_key(DWORD t) +{ + switch (t) { + case SERVICE_AUTO_START: return "automatic"; + case SERVICE_DEMAND_START: return "manual"; + case SERVICE_DISABLED: return "disabled"; + default: return "unknown"; + } +} + +const char *svc_status_path_check_key(int c) +{ + switch (c) { + case SVC_PATH_OK: return "ok"; + case SVC_PATH_UNQUOTED: return "unquoted"; + case SVC_PATH_OTHER: return "other"; + default: return "unknown"; + } +} + +const char *svc_status_source_check_key(int c) +{ + switch (c) { + case SVC_SRC_OK: return "registered"; + case SVC_SRC_MISSING: return "missing"; + case SVC_SRC_OTHER: return "other"; + default: return "unknown"; + } +} + +const char *svc_status_reason_key(svc_folder_reason why) +{ + switch (why) { + case SVC_FOLDER_OWNER: return "owner"; + case SVC_FOLDER_REPARSE: return "reparse_point"; + case SVC_FOLDER_KIND: return "not_directory"; + case SVC_FOLDER_NO_RIGHTS: return "acl"; + case SVC_FOLDER_ACL: return "acl"; + case SVC_FOLDER_NOT_FILE: return "not_file"; + default: return "ok"; + } +} + +void svc_status_write_members(sd_json_writer *jw, const svc_status_report *r, const svc_pipe_report *p, + const svc_machine_report *m) +{ + char path[1600], message_file[1600]; + utf8_of(r->binary, path, sizeof(path)); + utf8_of(r->message_file, message_file, sizeof(message_file)); + sd_jw_key(jw, "installed"); + sd_jw_bool(jw, r->installed); + sd_jw_key(jw, "state"); + sd_jw_cstr(jw, r->installed ? svc_status_state_key(r->state) : "not_installed"); + sd_jw_key(jw, "start_type"); + if (r->installed) sd_jw_cstr(jw, svc_status_start_type_key(r->start_type)); else sd_jw_null(jw); + sd_jw_key(jw, "program"); + if (r->installed) { + sd_jw_obj_begin(jw); + sd_jw_key(jw, "path"); + sd_jw_cstr(jw, path); + sd_jw_key(jw, "check"); + sd_jw_cstr(jw, svc_status_path_check_key(r->binary_check)); + sd_jw_obj_end(jw); + } else { + sd_jw_null(jw); + } + sd_jw_key(jw, "event_source"); + sd_jw_obj_begin(jw); + sd_jw_key(jw, "state"); + sd_jw_cstr(jw, svc_status_source_check_key(r->source_check)); + sd_jw_key(jw, "message_file"); + sd_jw_cstr(jw, message_file); + sd_jw_obj_end(jw); + sd_jw_key(jw, "pipe"); + if (p != NULL) { + sd_jw_obj_begin(jw); + sd_jw_key(jw, "reachable"); + sd_jw_bool(jw, p->reachable); + sd_jw_key(jw, "healthy"); + sd_jw_bool(jw, p->healthy); + sd_jw_key(jw, "global_drives"); + sd_jw_int(jw, p->drives); + sd_jw_key(jw, "prompts"); + sd_jw_int(jw, p->prompts); + sd_jw_obj_end(jw); + } else { + sd_jw_null(jw); + } + if (m != NULL) { + sd_jw_key(jw, "machine"); + sd_jw_obj_begin(jw); + sd_jw_key(jw, "state"); + sd_jw_cstr(jw, m->ok ? "ok" : "refused"); + if (!m->ok) { + sd_jw_key(jw, "reason"); + sd_jw_cstr(jw, svc_status_reason_key(m->why)); + if (m->path[0] != 0) { + char refused[1600]; + utf8_of(m->path, refused, sizeof(refused)); + sd_jw_key(jw, "path"); + sd_jw_cstr(jw, refused); + } + } + sd_jw_key(jw, "profiles"); + sd_jw_int(jw, m->profiles); + sd_jw_obj_end(jw); + } +} + +void svc_status_write_repair(sd_json_writer *jw, const svc_repair_result *r) +{ + char moved_to[1600]; + utf8_of(r->path, moved_to, sizeof(moved_to)); + sd_jw_key(jw, "moved"); + sd_jw_bool(jw, r->moved); + sd_jw_key(jw, "moved_to"); + if (r->moved) sd_jw_cstr(jw, moved_to); else sd_jw_null(jw); + sd_jw_key(jw, "folders"); + sd_jw_int(jw, (int64_t)r->folders); +} diff --git a/apps/shelldrive-host/svc_status.h b/apps/shelldrive-host/svc_status.h new file mode 100644 index 0000000..82b572b --- /dev/null +++ b/apps/shelldrive-host/svc_status.h @@ -0,0 +1,49 @@ +/* Service status report (03 §6.1 "service status", §2.4 service.status): the facts of `shelldrive service status` + * and the service-pipe method service.status, in one place so the two stay identical. + * + * svc_status_write_members writes the members of the report object into an open JSON object: the CLI wraps it in its + * own object (--json), the service writes it into the reply of service.status. The words (state, start type, checks) + * are English identifiers; clients localize by them. Pure: no SCM, pipe or file access here. */ +#ifndef SVC_STATUS_H +#define SVC_STATUS_H + +#include +#include + +#include "sd_base/sd_json.h" +#include "svc_folder.h" +#include "svc_repair.h" +#include "svc_scm.h" + +/* What the running service reports over its pipe. reachable = 0 when the pipe does not answer. */ +typedef struct svc_pipe_report { + int reachable; + int healthy; /* selftest: the machine data folder is trusted (3.2) */ + int64_t drives; /* global drives: visible ones plus the letters of the others (2.2) */ + int64_t prompts; /* open administrator prompts, from the content-free attention count (2.6) */ +} svc_pipe_report; + +/* The machine data folder as the service found it at start (3.2, TM-R34), written by service.status only. */ +typedef struct svc_machine_report { + int ok; /* 1 = the layout is trusted, 0 = refused: the service stays idle */ + svc_folder_reason why; /* the reason when refused */ + wchar_t path[MAX_PATH]; /* the first refused folder; empty for a caller that is not an elevated administrator */ + int64_t profiles; /* machine profiles loaded */ +} svc_machine_report; + +const char *svc_status_state_key(DWORD state); /* running, stopped, starting, stopping, paused, unknown */ +const char *svc_status_start_type_key(DWORD start_type); /* automatic, manual, disabled, unknown */ +const char *svc_status_path_check_key(int check); /* ok, unquoted, other, unknown */ +const char *svc_status_source_check_key(int check); /* registered, missing, other, unknown */ +const char *svc_status_reason_key(svc_folder_reason why);/* owner, reparse_point, not_directory, acl, not_file, ok */ + +/* Writes installed, state, start_type, program, event_source, pipe (null when pipe is NULL) and, when machine is not + NULL, machine {state: ok|refused, reason?, path?, profiles}. jw must be inside an open object. */ +void svc_status_write_members(sd_json_writer *jw, const svc_status_report *r, const svc_pipe_report *pipe, + const svc_machine_report *machine); + +/* Writes the members of a repair-data result: moved, moved_to (null unless moved) and folders. Shared by + `service repair-data --json` and the service.repair_data reply. jw must be inside an open object. */ +void svc_status_write_repair(sd_json_writer *jw, const svc_repair_result *r); + +#endif /* SVC_STATUS_H */ diff --git a/apps/shelldrive-host/update_ipc.c b/apps/shelldrive-host/update_ipc.c new file mode 100644 index 0000000..e4802a6 --- /dev/null +++ b/apps/shelldrive-host/update_ipc.c @@ -0,0 +1,518 @@ +/* shelldrive-host update methods (see update_ipc.h). Configuration precedence (04 §9.1, 03 §3.5): + * policy (sd_policy: HKLM, then HKCU; the update policies of 03 §3.5) > settings.ini [update] of the user + * (or of \data for a portable install) > settings.ini [update] of the machine (%ProgramData%) > defaults. + * Defaults: enabled, channel stable, the GitHub bootstrap manifest. Trust: the release roots are compiled in by the + * release build (04 §9.3); this build carries none, so every verification fails closed until they are added. */ +#include +#include +#include +#include + +#include "update_ipc.h" +#include "sd/version.h" +#include "sd_base/sd_mem.h" +#include "sd_base/sd_utf.h" +#include "sd_config/sd_ini.h" +#include "sd_ipc/sd_ipc.h" +#include "sd_policy/sd_policy.h" +#include "sd_platform/sd_log.h" +#include "sd_platform/sd_thread.h" +#include "sd_platform/sd_time.h" +#include "sd_update/sd_update.h" + +#define UPD_DEFAULT_URL L"https://github.com/LibreStatic/ShellDrive/releases/latest/download/manifest.txt" +#define UPD_INI_MAX (64u * 1024u) +#define UPD_APPLY_MS 600000u /* the staged setup may wait for UAC and a file lock */ + +/* One check or apply at a time, per agent. */ +static volatile LONG g_busy; + +/* No release roots in this build (see the header comment). */ +static const sd_upd_trust k_trust = { NULL, 0, NULL, 0 }; + +typedef struct upd_cfg { + int enabled; /* 0: checks disabled (reason says why) */ + const char *reason; /* "policy" | "settings" */ + int portable; + char channel[8]; + wchar_t url[2049]; /* the UpdateUrl policy allows 2048 characters */ + wchar_t state_user[MAX_PATH]; + wchar_t state_machine[MAX_PATH]; + wchar_t staging[MAX_PATH]; + sd_upd_mode mode; + sd_upd_state_dirs st; + sd_upd_paths paths; +} upd_cfg; + +typedef struct upd_job { + sd_ipc_server *srv; + uint64_t cid; + int64_t rid; + int apply; + upd_cfg cfg; +} upd_job; + +static sd_err fail(sd_ipc_error *err, sd_err e, const char *code, const char *detail) +{ + sd_ipc_error_set(err, e, code, detail); + return err->err; +} + +/* ---- configuration ---- */ + +static int expand_env(const wchar_t *in, wchar_t *out, DWORD cch) +{ + DWORD n = ExpandEnvironmentStringsW(in, out, cch); + return n > 0 && n <= cch; +} + +/* Creates every missing directory of path. */ +static void make_dirs(const wchar_t *path) +{ + wchar_t tmp[MAX_PATH]; + size_t i; + tmp[0] = L'\0'; + if (wcslen(path) >= SD_COUNTOF(tmp)) + return; + wcscpy_s(tmp, SD_COUNTOF(tmp), path); + for (i = 1; i < SD_COUNTOF(tmp) && tmp[i] != L'\0'; i++) { + if (tmp[i] == L'\\') { + tmp[i] = L'\0'; + CreateDirectoryW(tmp, NULL); + tmp[i] = L'\\'; + } + } + CreateDirectoryW(tmp, NULL); +} + +static int read_small_file(const wchar_t *path, uint8_t **buf, size_t *len) +{ + HANDLE h = CreateFileW(path, GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, NULL, + OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL); + LARGE_INTEGER sz; + DWORD got = 0; + uint8_t *p; + if (h == INVALID_HANDLE_VALUE) + return 0; + if (!GetFileSizeEx(h, &sz) || sz.QuadPart < 0 || (unsigned long long)sz.QuadPart > UPD_INI_MAX) { + CloseHandle(h); + return 0; + } + p = (uint8_t *)sd_alloc((size_t)sz.QuadPart + 1); + if (p == NULL) { + CloseHandle(h); + return 0; + } + if (sz.QuadPart && (!ReadFile(h, p, (DWORD)sz.QuadPart, &got, NULL) || got != (DWORD)sz.QuadPart)) { + CloseHandle(h); + sd_free(p); + return 0; + } + CloseHandle(h); + *buf = p; + *len = got; + return 1; +} + +static void load_ini(const wchar_t *path, upd_cfg *c) +{ + uint8_t *buf = NULL; + size_t n = 0; + sd_ini *ini = NULL; + uint32_t flags = 0; + const char *v; + if (!read_small_file(path, &buf, &n)) + return; + if (sd_ini_parse((const char *)buf, n, &ini, &flags) == SD_OK) { + v = sd_ini_get(ini, "update", "enabled"); + if (v) { + int b = 1; + if (sd_ini_parse_bool(v, &b)) + c->enabled = b; + } + v = sd_ini_get(ini, "update", "channel"); + if (v && (strcmp(v, "stable") == 0 || strcmp(v, "beta") == 0)) + strcpy_s(c->channel, sizeof(c->channel), v); + v = sd_ini_get(ini, "update", "manifest_url"); + if (v && (strncmp(v, "https://", 8) == 0 || strncmp(v, "http://", 7) == 0)) { + wchar_t *w = NULL; + if (sd_utf8_to_wide_alloc(v, strlen(v), SD_UTF_STRICT, &w) == SD_OK && wcslen(w) < SD_COUNTOF(c->url)) + wcscpy_s(c->url, SD_COUNTOF(c->url), w); + sd_free(w); + } + sd_ini_free(ini); + } + sd_free(buf); +} + +/* Points the engine's directory arguments at the arrays of this very struct. Call it on the copy that is + used (a copied struct keeps pointers into the original). */ +static void cfg_bind(upd_cfg *c) +{ + c->st.user = c->state_user[0] ? c->state_user : NULL; + c->st.machine = c->state_machine[0] ? c->state_machine : NULL; + c->paths.staging = c->staging[0] ? c->staging : NULL; + c->paths.install = NULL; /* setup mode: the setup program replaces the files (04 §9.6) */ + c->paths.selftest_exe = L"shelldrive-host.exe"; +} + +static void load_config(upd_cfg *c) +{ + wchar_t exe[MAX_PATH], dir[MAX_PATH], path[MAX_PATH * 2]; + DWORD dw = 0; + char chan[8]; + memset(c, 0, sizeof(*c)); + c->enabled = 1; + strcpy_s(c->channel, sizeof(c->channel), "stable"); + wcscpy_s(c->url, SD_COUNTOF(c->url), UPD_DEFAULT_URL); + + if (GetModuleFileNameW(NULL, exe, SD_COUNTOF(exe)) == 0) + exe[0] = L'\0'; + wcscpy_s(dir, SD_COUNTOF(dir), exe); + { + wchar_t *slash = wcsrchr(dir, L'\\'); + if (slash) + *slash = L'\0'; + } + _snwprintf_s(path, SD_COUNTOF(path), _TRUNCATE, L"%ls\\data\\settings.ini", dir); + c->portable = GetFileAttributesW(path) != INVALID_FILE_ATTRIBUTES; + + if (expand_env(L"%ProgramData%\\LibreStatic\\ShellDrive\\settings.ini", path, SD_COUNTOF(path))) + load_ini(path, c); + if (c->portable) { + _snwprintf_s(path, SD_COUNTOF(path), _TRUNCATE, L"%ls\\data\\settings.ini", dir); + load_ini(path, c); + } else if (expand_env(L"%APPDATA%\\LibreStatic\\ShellDrive\\settings.ini", path, SD_COUNTOF(path))) { + load_ini(path, c); + } + + /* policy: HKLM wins over HKCU (03 §3.5) */ + if (sd_policy_get_dword(SD_POLICY_DISABLEUPDATECHECK, &dw)) { + if (dw != 0) { + c->enabled = 0; + c->reason = "policy"; + } + } + { + wchar_t pv[64]; + int have = sd_policy_get_sz(SD_POLICY_UPDATECHANNEL, pv, SD_COUNTOF(pv)); + if (have) { + size_t i; + for (i = 0; i < 8 && pv[i]; i++) + chan[i] = (char)pv[i]; + chan[i < 8 ? i : 7] = '\0'; + if (strcmp(chan, "stable") == 0 || strcmp(chan, "beta") == 0) + strcpy_s(c->channel, sizeof(c->channel), chan); + } + } + { + wchar_t pu[2049]; + if (sd_policy_get_sz(SD_POLICY_UPDATEURL, pu, SD_COUNTOF(pu))) + wcscpy_s(c->url, SD_COUNTOF(c->url), pu); + } + if (c->enabled == 0 && c->reason == NULL) + c->reason = "settings"; + + /* state and staging: per user, or next to the executable for a portable install (04 §9.2, §9.6) */ + if (c->portable) { + c->mode = SD_UPD_MODE_PORTABLE; + _snwprintf_s(c->state_user, SD_COUNTOF(c->state_user), _TRUNCATE, L"%ls\\data\\state", dir); + _snwprintf_s(c->staging, SD_COUNTOF(c->staging), _TRUNCATE, L"%ls\\data\\updates", dir); + } else { + c->mode = SD_UPD_MODE_SETUP; + if (expand_env(L"%LOCALAPPDATA%\\LibreStatic\\ShellDrive\\state", c->state_user, SD_COUNTOF(c->state_user)) == 0) + c->state_user[0] = L'\0'; + if (expand_env(L"%LOCALAPPDATA%\\LibreStatic\\ShellDrive\\updates", c->staging, SD_COUNTOF(c->staging)) == 0) + c->staging[0] = L'\0'; + } + if (!expand_env(L"%ProgramData%\\LibreStatic\\ShellDrive\\state", c->state_machine, SD_COUNTOF(c->state_machine))) + c->state_machine[0] = L'\0'; + + if (c->state_user[0]) + make_dirs(c->state_user); + if (c->staging[0]) + make_dirs(c->staging); + cfg_bind(c); +} + +/* ---- JSON helpers ---- */ + +static void write_offer(sd_json_writer *jw, const sd_upd_offer *o) +{ + sd_jw_key(jw, "available"); sd_jw_bool(jw, o->available); + sd_jw_key(jw, "channel"); sd_jw_cstr(jw, o->channel); + sd_jw_key(jw, "stale"); sd_jw_bool(jw, o->stale); + if (o->available) { + sd_jw_key(jw, "version"); sd_jw_cstr(jw, o->version); + sd_jw_key(jw, "notes"); sd_jw_cstr(jw, o->notes); + sd_jw_key(jw, "size"); sd_jw_int(jw, (int64_t)o->size); + sd_jw_key(jw, "file_id"); sd_jw_cstr(jw, o->file_id); + sd_jw_key(jw, "serial"); sd_jw_int(jw, (int64_t)o->serial); + } +} + +static void broadcast(sd_ipc_server *srv, const char *event, sd_wr *data) +{ + sd_wr w; + sd_wr_init(&w, SD_IPC_MAX_MESSAGE); + if (srv && sd_ipc_build_event(event, (const char *)data->p, data->len, &w) == SD_OK) + (void)sd_ipc_broadcast_all(srv, (const char *)w.p, w.len); + sd_wr_free(&w); +} + +static void event_offer(sd_ipc_server *srv, const sd_upd_offer *o) +{ + sd_wr d; + sd_json_writer jw; + sd_wr_init(&d, 4096); + sd_jw_init(&jw, &d); + sd_jw_obj_begin(&jw); + sd_jw_key(&jw, "version"); sd_jw_cstr(&jw, o->version); + sd_jw_key(&jw, "channel"); sd_jw_cstr(&jw, o->channel); + sd_jw_key(&jw, "notes"); sd_jw_cstr(&jw, o->notes); + sd_jw_key(&jw, "size"); sd_jw_int(&jw, (int64_t)o->size); + sd_jw_key(&jw, "file_id"); sd_jw_cstr(&jw, o->file_id); + sd_jw_obj_end(&jw); + if (sd_jw_finish(&jw) == SD_OK) + broadcast(srv, "update.available", &d); + sd_wr_free(&d); +} + +static void event_progress(sd_ipc_server *srv, const char *stage, const sd_upd_offer *o) +{ + sd_wr d; + sd_json_writer jw; + sd_wr_init(&d, 1024); + sd_jw_init(&jw, &d); + sd_jw_obj_begin(&jw); + sd_jw_key(&jw, "stage"); sd_jw_cstr(&jw, stage); + sd_jw_key(&jw, "version"); sd_jw_cstr(&jw, o->version); + sd_jw_obj_end(&jw); + if (sd_jw_finish(&jw) == SD_OK) + broadcast(srv, "update.progress", &d); + sd_wr_free(&d); +} + +static void event_stale(sd_ipc_server *srv, const upd_cfg *c) +{ + sd_wr d; + sd_json_writer jw; + int64_t last = 0; + int64_t days = 0; + if (sd_upd_state_read_verified(&c->st, c->channel, &last) == SD_UPD_OK && last > 0) { + days = (sd_unix_time_now() - last) / 86400; + if (days < 0) days = 0; + } + sd_wr_init(&d, 256); + sd_jw_init(&jw, &d); + sd_jw_obj_begin(&jw); + sd_jw_key(&jw, "days"); sd_jw_int(&jw, days); + sd_jw_obj_end(&jw); + if (sd_jw_finish(&jw) == SD_OK) + broadcast(srv, "update.stale", &d); + sd_wr_free(&d); +} + +/* ---- engine calls ---- */ + +static sd_upd_res do_check(const upd_cfg *c, sd_upd_transport *tr, sd_upd_request *r, sd_upd_offer *o, char *flavour, size_t fcap) +{ + sd_upd_res res = sd_upd_detect_flavour(flavour, fcap); + if (res != SD_UPD_OK) + return res; + memset(r, 0, sizeof(*r)); + r->manifest_url = c->url; + r->channel = c->channel; + r->installed_version = SD_VERSION_STRING; + r->flavour = flavour; + r->mode = c->mode; + r->now = sd_unix_time_now(); + r->tr = tr; + r->trust = &k_trust; + r->state = &c->st; + r->paths = &c->paths; + return sd_upd_check(r, o); +} + +static void job_error(upd_job *j, sd_err e, const char *code, const char *detail) +{ + if (j->srv) + (void)sd_ipc_reply_deferred(j->srv, j->cid, j->rid, e, code, detail, NULL, 0); +} + +static void job_reply(upd_job *j, const sd_wr *d) +{ + if (j->srv) + (void)sd_ipc_reply_deferred(j->srv, j->cid, j->rid, SD_OK, NULL, NULL, (const char *)d->p, d->len); +} + +static unsigned __stdcall job_main(void *arg) +{ + upd_job *j = (upd_job *)arg; + sd_upd_offer *o = (sd_upd_offer *)sd_calloc(1, sizeof(*o)); + sd_upd_request r; + sd_upd_transport *tr = NULL; + sd_upd_res res = SD_UPD_E_NO_MEMORY; + char flavour[16]; + sd_wr d; + sd_json_writer jw; + + if (o == NULL) { + job_error(j, SD_E_NO_MEMORY, "no_memory", "out of memory"); + goto out; + } + tr = sd_upd_transport_winhttp_create(1); + if (tr == NULL) { + job_error(j, SD_E_FAILURE, "update_unavailable", "WinHTTP cannot be loaded"); + goto out; + } + + res = do_check(&j->cfg, tr, &r, o, flavour, sizeof(flavour)); + if (res != SD_UPD_OK) { + if (o->stale) + event_stale(j->srv, &j->cfg); + SD_LOGW("agent", "update.check failed: %s", sd_upd_res_name(res)); + job_error(j, SD_E_FAILURE, sd_upd_res_code(res), sd_upd_res_name(res)); + goto out; + } + if (o->available) + event_offer(j->srv, o); + + if (!j->apply) { + sd_wr_init(&d, 4096); + sd_jw_init(&jw, &d); + sd_jw_obj_begin(&jw); + sd_jw_key(&jw, "enabled"); sd_jw_bool(&jw, 1); + write_offer(&jw, o); + sd_jw_obj_end(&jw); + if (sd_jw_finish(&jw) == SD_OK) + job_reply(j, &d); + else + job_error(j, SD_E_INTERNAL, "internal", "reply"); + sd_wr_free(&d); + goto out; + } + + /* update.apply: stage, then the staged setup with /update /passive (04 §9.6) */ + if (!o->available) { + sd_wr_init(&d, 1024); + sd_jw_init(&jw, &d); + sd_jw_obj_begin(&jw); + sd_jw_key(&jw, "applied"); sd_jw_bool(&jw, 0); + sd_jw_key(&jw, "state"); sd_jw_cstr(&jw, "up_to_date"); + sd_jw_obj_end(&jw); + if (sd_jw_finish(&jw) == SD_OK) + job_reply(j, &d); + sd_wr_free(&d); + goto out; + } + { + wchar_t staged[MAX_PATH * 2]; + event_progress(j->srv, "downloading", o); + res = sd_upd_stage(&r, o, staged, SD_COUNTOF(staged)); + if (res != SD_UPD_OK) { + job_error(j, SD_E_FAILURE, sd_upd_res_code(res), sd_upd_res_name(res)); + goto out; + } + event_progress(j->srv, "applying", o); + res = sd_upd_apply_setup(staged, UPD_APPLY_MS); + if (res != SD_UPD_OK) { + event_progress(j->srv, "failed", o); + job_error(j, SD_E_FAILURE, sd_upd_res_code(res), sd_upd_res_name(res)); + goto out; + } + sd_upd_cleanup_staging(j->cfg.staging, o->version); + event_progress(j->srv, "done", o); + sd_wr_init(&d, 1024); + sd_jw_init(&jw, &d); + sd_jw_obj_begin(&jw); + sd_jw_key(&jw, "applied"); sd_jw_bool(&jw, 1); + sd_jw_key(&jw, "state"); sd_jw_cstr(&jw, "applied"); + sd_jw_key(&jw, "version"); sd_jw_cstr(&jw, o->version); + sd_jw_obj_end(&jw); + if (sd_jw_finish(&jw) == SD_OK) + job_reply(j, &d); + sd_wr_free(&d); + } + +out: + if (tr) + sd_upd_transport_destroy(tr); + sd_free(o); + sd_free(j); + InterlockedExchange(&g_busy, 0); + return 0; +} + +static sd_err start_job(sd_ipc_server *srv, sd_ipc_client *client, upd_job *j, sd_ipc_error *err) +{ + sd_thread t; + if (!sd_ipc_defer(client, &j->rid)) { + sd_free(j); + InterlockedExchange(&g_busy, 0); + return fail(err, SD_E_INTERNAL, "internal", "deferred reply unavailable"); + } + j->srv = srv; + j->cid = sd_ipc_client_id(client); + cfg_bind(&j->cfg); + if (sd_thread_create(&t, job_main, j, 0, L"sd-update") != SD_OK) { + /* the client is waiting: answer now, nothing ran */ + (void)sd_ipc_reply_deferred(srv, j->cid, j->rid, SD_E_NO_MEMORY, "no_memory", "cannot start the update thread", NULL, 0); + sd_free(j); + InterlockedExchange(&g_busy, 0); + return SD_OK; + } + CloseHandle(t.h); + return SD_OK; +} + +/* ---- methods ---- */ + +sd_err upd_method_check(sd_ipc_server *srv, sd_ipc_client *client, const sd_json *params, sd_json_writer *res, sd_ipc_error *err) +{ + upd_cfg c; + upd_job *j; + SD_UNUSED(params); + load_config(&c); + if (!c.enabled) { + sd_jw_key(res, "enabled"); sd_jw_bool(res, 0); + sd_jw_key(res, "reason"); sd_jw_cstr(res, c.reason ? c.reason : "settings"); + sd_jw_key(res, "available"); sd_jw_bool(res, 0); + return SD_OK; + } + if (InterlockedCompareExchange(&g_busy, 1, 0) != 0) + return fail(err, SD_E_BUSY, "busy", "an update check or install is running"); + j = (upd_job *)sd_calloc(1, sizeof(*j)); + if (j == NULL) { + InterlockedExchange(&g_busy, 0); + return fail(err, SD_E_NO_MEMORY, "no_memory", "out of memory"); + } + j->cfg = c; + j->apply = 0; + return start_job(srv, client, j, err); +} + +sd_err upd_method_apply(sd_ipc_server *srv, sd_ipc_client *client, const sd_json *params, sd_json_writer *res, sd_ipc_error *err) +{ + upd_cfg c; + upd_job *j; + int consent = 0; + SD_UNUSED(res); + if (!sd_json_get_bool(params, "consent", &consent) || !consent) + return fail(err, SD_E_INVALID_PARAM, "consent_required", "update.apply needs consent: true (the user chose to install)"); + load_config(&c); + if (!c.enabled) + return fail(err, SD_E_UNSUPPORTED, "update_disabled", c.reason ? c.reason : "disabled"); + if (c.portable) + return fail(err, SD_E_UNSUPPORTED, "update_disabled", "portable install: download the new version manually"); + if (InterlockedCompareExchange(&g_busy, 1, 0) != 0) + return fail(err, SD_E_BUSY, "busy", "an update check or install is running"); + j = (upd_job *)sd_calloc(1, sizeof(*j)); + if (j == NULL) { + InterlockedExchange(&g_busy, 0); + return fail(err, SD_E_NO_MEMORY, "no_memory", "out of memory"); + } + j->cfg = c; + j->apply = 1; + return start_job(srv, client, j, err); +} diff --git a/apps/shelldrive-host/update_ipc.h b/apps/shelldrive-host/update_ipc.h new file mode 100644 index 0000000..d9297ab --- /dev/null +++ b/apps/shelldrive-host/update_ipc.h @@ -0,0 +1,23 @@ +/* shelldrive-host update methods (04 §9.1, 03 §2.8): update.check and update.apply, and the events update.available + * and update.progress. The engine is sd_update; this file owns the policy and settings reads (03 §3.5, 04 §9.1). + * + * Both methods answer through sd_ipc_defer: the network work runs on a worker thread and the reply is sent with + * sd_ipc_reply_deferred, so the pipe thread never waits on the network. Only one check or apply runs at a time + * (busy). */ +#ifndef SD_HOST_UPDATE_IPC_H +#define SD_HOST_UPDATE_IPC_H + +#include "sd_base/sd_json.h" +#include "sd_ipc/sd_ipc.h" + +/* update.check {}: {enabled, reason?, available, version?, channel, notes?, size?, file_id?, released?, expires?, + stale, stale_days?}. Disabled by policy/settings: enabled=false, no network. */ +sd_err upd_method_check(sd_ipc_server *srv, sd_ipc_client *client, const sd_json *params, sd_json_writer *res, + sd_ipc_error *err); + +/* update.apply {consent: true, version?}: consent is required. Reply {applied, version?, state}. + Portable installs are not applied (update_disabled): the UI offers the manual download. */ +sd_err upd_method_apply(sd_ipc_server *srv, sd_ipc_client *client, const sd_json *params, sd_json_writer *res, + sd_ipc_error *err); + +#endif /* SD_HOST_UPDATE_IPC_H */ diff --git a/apps/shelldrive-setup/CMakeLists.txt b/apps/shelldrive-setup/CMakeLists.txt new file mode 100644 index 0000000..a123a14 --- /dev/null +++ b/apps/shelldrive-setup/CMakeLists.txt @@ -0,0 +1,91 @@ +# shelldrive-setup (docs/design/04 §6): the setup engine, the setup stub and its payload. +# +# sd_setup_core engine library (all presets; tests/setup links it) +# shelldrive-setup-stub the stub without payload (x86-ia32 preset: an x86 program that runs on every x86 and +# x64 CPU, 04 §6.1; arm64 and arm32 presets: the native ARM64 and ARM32 stubs, §1.1, §5.1). It is +# also uninstall.exe inside the payload. +# shelldrive-setup the stub with the payload of this preset attached (tools/sd-payload). The arm64 CAB holds the +# ARM64 binaries only; the x86-sse2 and x64-modern shell DLLs that the ARM64 payload also +# carries (04 §6.1) are added by the release pipeline, not by this preset. The arm32 CAB holds +# the ARM32 CLI and host only (04 §5.1). + +include(SdPayload) + +sd_add_l10n_catalog(installer DOMAINS installer) + +sd_setup_generate_regtable(SD_SETUP_REGTABLE_C) + +# The TM-R34 folder check is the service's (svc_repair.c, svc_folder.c): the same source files, not a copy. +set(_sd_setup_host_dir "${CMAKE_SOURCE_DIR}/apps/shelldrive-host") + +# Payload table signature (TM-R38): pure verifier plus the resource wrapper. Shared with tools/sd-release and its +# tests; sd_crypto only (sd_ed25519_verify and SHA-256 run on XP and in the stub). +add_library(sd_payload_sig STATIC setup_payload_sig.c) +target_include_directories(sd_payload_sig PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}") +target_link_libraries(sd_payload_sig PUBLIC sd_crypto sd_headers) +target_compile_definitions(sd_payload_sig PUBLIC "SD_SETUP_BUILD_CHANNEL=\"${SD_SETUP_CHANNEL}\"") +set_target_properties(sd_payload_sig PROPERTIES FOLDER "apps") + +add_library(sd_setup_core STATIC + setup_args.c + setup_sha256.c + setup_cab.c + setup_util.c + setup_reg.c + setup_host.c + setup_log.c + setup_shortcut.c + setup_msg.c + setup_flow.c + setup_install.c + setup_trust.c + setup_elevate.c + setup_dokany.c + "${_sd_setup_host_dir}/svc_folder.c" + "${_sd_setup_host_dir}/svc_repair.c" + ${SD_SETUP_REGTABLE_C}) +target_include_directories(sd_setup_core PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}") +target_include_directories(sd_setup_core PRIVATE "${_sd_setup_host_dir}") +target_link_libraries(sd_setup_core PUBLIC sd_headers sd_l10n sd_l10n_catalog_installer sd_base sd_payload_sig) +set_target_properties(sd_setup_core PROPERTIES FOLDER "apps") + +if(SD_FLAVOUR STREQUAL "x86-ia32" OR SD_FLAVOUR STREQUAL "arm64" OR SD_FLAVOUR STREQUAL "arm32") + set(SD_RC_DESCRIPTION "ShellDrive setup") + set(SD_RC_INTERNAL_NAME "shelldrive-setup") + set(SD_RC_ORIGINAL_FILENAME "shelldrive-setup.exe") + set(_rc "${CMAKE_CURRENT_BINARY_DIR}/shelldrive-setup.version.rc") + configure_file("${CMAKE_SOURCE_DIR}/res/version.rc.in" "${_rc}" @ONLY) + set(_manifest "${CMAKE_CURRENT_BINARY_DIR}/shelldrive-setup.manifest") + sd_manifest_configure("${_manifest}" "shelldrive-setup" TRUE) + + # GUI subsystem, but no comctl32 import: common controls come from the manifest only (04 §2.4). + add_executable(shelldrive-setup-stub WIN32 setup_main.c setup_ui.c "${_rc}" "${_manifest}") + set_target_properties(shelldrive-setup-stub PROPERTIES OUTPUT_NAME "shelldrive-setup-stub" FOLDER "apps") + target_link_libraries(shelldrive-setup-stub PRIVATE sd_setup_core sd_l10n sd_l10n_catalog_installer sd_headers) + if(SD_SUBSYSTEM_VERSION) + target_link_options(shelldrive-setup-stub PRIVATE "/SUBSYSTEM:WINDOWS,${SD_SUBSYSTEM_VERSION}") + endif() + sd_add_import_audit(shelldrive-setup-stub shelldrive-setup-stub) + sd_add_isa_check(shelldrive-setup-stub shelldrive-setup-stub) + + # Only KnownDLLs may be imported by the stub and uninstall.exe (TM-R33, TST-30). + add_test(NAME importcheck.stub.knowndlls + COMMAND sd-importcheck --floor "${SD_OS_FLOOR_LIST}" + --knowndlls "${CMAKE_SOURCE_DIR}/tests/os-imports/knowndlls.txt" --no-delayload + "$") + set_tests_properties(importcheck.stub.knowndlls PROPERTIES LABELS importcheck) + + # Hidden /test-pages: the wizard pages are built and logged, nothing is installed (04 §6.10, tests/setup). + set(_tp_dir "${CMAKE_BINARY_DIR}/test-pages") + file(MAKE_DIRECTORY "${_tp_dir}") + add_test(NAME setup.stub.test_pages + COMMAND $ /test-pages "/log=${_tp_dir}/pages.log" + "/installdir=${_tp_dir}/install") + add_test(NAME setup.stub.test_pages_log + COMMAND "${CMAKE_COMMAND}" "-DLOG=${_tp_dir}/pages.log" "-DNOT_CREATED=${_tp_dir}/install" + -P "${CMAKE_SOURCE_DIR}/tests/setup/check_test_pages.cmake") + set_tests_properties(setup.stub.test_pages setup.stub.test_pages_log PROPERTIES LABELS setup TIMEOUT 120) + set_tests_properties(setup.stub.test_pages_log PROPERTIES DEPENDS setup.stub.test_pages) + + sd_setup_add_payload(shelldrive-setup-stub SD_SETUP_EXE "${SD_FLAVOUR}") +endif() diff --git a/apps/shelldrive-setup/setup_args.c b/apps/shelldrive-setup/setup_args.c new file mode 100644 index 0000000..73f4b95 --- /dev/null +++ b/apps/shelldrive-setup/setup_args.c @@ -0,0 +1,614 @@ +/* shelldrive-setup: command line, host detection, flavour and Dokany decisions, version order, payload + table lookup and CAB member-name rules (docs/design/04 §6.2, §6.5, §7.3, §6.1, TM-R33). Pure logic + first; the detection functions only read facts. */ +#include "setup_int.h" + +#include +#include +#include +#include + +#ifndef PROCESSOR_ARCHITECTURE_ARM64 +#define PROCESSOR_ARCHITECTURE_ARM64 12 +#endif + +/* ------------------------------------------------------------------ flavours */ + +static const wchar_t *const k_flavour_names[] = { + NULL, L"x86-ia32", L"x86-sse2", L"x64-xp", L"x64-modern", L"arm64", L"arm32" +}; + +static const wchar_t *const k_payload_names[] = { + NULL, L"PAYLOAD_X86_IA32", L"PAYLOAD_X86_SSE2", L"PAYLOAD_X64_XP", L"PAYLOAD_X64_MODERN", L"PAYLOAD_ARM64", + L"PAYLOAD_ARM32" +}; + +const wchar_t *sd_setup_flavour_name(sd_setup_flavour f) +{ + if ((int)f < 0 || (size_t)f >= SD_SETUP_COUNTOF(k_flavour_names)) + return NULL; + return k_flavour_names[f]; +} + +const wchar_t *sd_setup_payload_resource(sd_setup_flavour f) +{ + if ((int)f <= 0 || (size_t)f >= SD_SETUP_COUNTOF(k_payload_names)) + return NULL; + return k_payload_names[f]; +} + +sd_setup_flavour sd_setup_flavour_parse(const wchar_t *name) +{ + size_t i; + if (name == NULL) + return SD_SETUP_FL_NONE; + for (i = 1; i < SD_SETUP_COUNTOF(k_flavour_names); i++) { + if (_wcsicmp(name, k_flavour_names[i]) == 0) + return (sd_setup_flavour)i; + } + return SD_SETUP_FL_NONE; +} + +/* ------------------------------------------------------------------ command line */ + +static int arg_is(const wchar_t *tok, const wchar_t *name) +{ + return _wcsicmp(tok, name) == 0; +} + +/* Matches "/name=" (case-insensitive) and returns the value after '=' or NULL. */ +static const wchar_t *arg_value(const wchar_t *tok, const wchar_t *name) +{ + size_t n = wcslen(name); + if (tok[0] != L'/' && tok[0] != L'-') + return NULL; + if (_wcsnicmp(tok + 1, name, n) != 0) + return NULL; + if (tok[1 + n] != L'=') + return NULL; + return tok + 2 + n; +} + +static int copy_path_arg(wchar_t *dst, size_t cch, const wchar_t *src) +{ + if (src[0] == L'\0') + return 0; + return SUCCEEDED(StringCchCopyW(dst, cch, src)) ? 1 : 0; +} + +static int is_lang_code(const wchar_t *s) +{ + static const wchar_t *const langs[] = { L"en", L"es", L"fr", L"pt-BR", L"pt", L"it", L"de" }; + size_t i; + for (i = 0; i < SD_SETUP_COUNTOF(langs); i++) { + if (_wcsicmp(s, langs[i]) == 0) + return 1; + } + return 0; +} + +static int is_property_token(const wchar_t *tok) +{ + const wchar_t *eq = wcschr(tok, L'='); + const wchar_t *p; + if (eq == NULL || eq == tok) + return 0; + for (p = tok; p < eq; p++) { + if (!(iswalnum(*p) || *p == L'_')) + return 0; + } + return 1; +} + +int sd_setup_parse_args(int argc, const wchar_t *const *argv, sd_setup_args *out, const wchar_t **bad) +{ + int i; + int mode_given = 0; + + memset(out, 0, sizeof(*out)); + out->autostart = -1; + out->mode = SD_SETUP_MODE_AUTO; + if (bad != NULL) + *bad = NULL; + + for (i = 1; i < argc; i++) { + const wchar_t *tok = argv[i]; + const wchar_t *v; + + if (tok == NULL || tok[0] == L'\0') + goto bad_token; + + if (tok[0] != L'/' && tok[0] != L'-') { + if (is_property_token(tok) && wcschr(tok, L'"') == NULL) { + /* Kept quoted for the msiexec command line (04 §8.4); the elevated stages receive them the same way. */ + size_t used = wcslen(out->props); + size_t need = wcslen(tok) + 4; + if (used + need >= SD_SETUP_COUNTOF(out->props)) + goto bad_token; + StringCchPrintfW(out->props + used, SD_SETUP_COUNTOF(out->props) - used, L"\"%s\" ", tok); + out->properties++; + continue; + } + goto bad_token; + } + + if (arg_is(tok + 1, L"quiet") || arg_is(tok + 1, L"q")) { + out->quiet = 1; + } else if (arg_is(tok + 1, L"passive")) { + out->passive = 1; + } else if (arg_is(tok + 1, L"norestart")) { + out->norestart = 1; + } else if (arg_is(tok + 1, L"force")) { + out->force = 1; + } else if (arg_is(tok + 1, L"update")) { + out->update = 1; + } else if (arg_is(tok + 1, L"uninstall")) { + if (mode_given && out->mode != SD_SETUP_MODE_UNINSTALL) + goto bad_token; + out->mode = SD_SETUP_MODE_UNINSTALL; + mode_given = 1; + } else if (arg_is(tok + 1, L"uninstall-from-temp")) { + out->uninstall_relaunched = 1; + out->mode = SD_SETUP_MODE_UNINSTALL; + mode_given = 1; + } else if (arg_is(tok + 1, L"peruser")) { + if (mode_given && out->mode != SD_SETUP_MODE_USER) + goto bad_token; + out->mode = SD_SETUP_MODE_USER; + mode_given = 1; + } else if (arg_is(tok + 1, L"permachine")) { + if (mode_given && out->mode != SD_SETUP_MODE_MACHINE) + goto bad_token; + out->mode = SD_SETUP_MODE_MACHINE; + mode_given = 1; + } else if (arg_is(tok + 1, L"help") || arg_is(tok + 1, L"?")) { + out->help = 1; + } else if (arg_is(tok + 1, L"test-pages")) { + out->test_pages = 1; + } else if (arg_is(tok + 1, L"elevated-stage1") || arg_is(tok + 1, L"elevated-child")) { + /* Internal: the two elevated stages (setup_elevate.c). Only one of them per command line. */ + int stage = arg_is(tok + 1, L"elevated-stage1") ? 1 : 2; + if (out->elevate_stage != 0 && out->elevate_stage != stage) + goto bad_token; + out->elevate_stage = stage; + } else if ((v = arg_value(tok, L"pipe")) != NULL) { + if (!copy_path_arg(out->pipe_name, SD_SETUP_COUNTOF(out->pipe_name), v)) + goto bad_token; + } else if ((v = arg_value(tok, L"portable")) != NULL) { + if (mode_given && out->mode != SD_SETUP_MODE_PORTABLE) + goto bad_token; + if (!copy_path_arg(out->portable_dir, SD_SETUP_COUNTOF(out->portable_dir), v)) + goto bad_token; + out->mode = SD_SETUP_MODE_PORTABLE; + mode_given = 1; + } else if ((v = arg_value(tok, L"dokany")) != NULL) { + if (arg_is(v, L"auto")) { + out->dokany = SD_SETUP_DOKANY_AUTO; + } else if (arg_is(v, L"install")) { + out->dokany = SD_SETUP_DOKANY_INSTALL; + } else if (arg_is(v, L"skip")) { + out->dokany = SD_SETUP_DOKANY_SKIP; + } else { + goto bad_token; + } + out->dokany_set = 1; + } else if ((v = arg_value(tok, L"autostart")) != NULL) { + if (v[0] == L'0' && v[1] == L'\0') + out->autostart = 0; + else if (v[0] == L'1' && v[1] == L'\0') + out->autostart = 1; + else + goto bad_token; + } else if ((v = arg_value(tok, L"flavour")) != NULL) { + out->flavour = sd_setup_flavour_parse(v); + if (out->flavour == SD_SETUP_FL_NONE) + goto bad_token; + out->flavour_forced = 1; + } else if ((v = arg_value(tok, L"lang")) != NULL) { + if (!is_lang_code(v) || wcslen(v) >= SD_SETUP_COUNTOF(out->lang)) + goto bad_token; + StringCchCopyW(out->lang, SD_SETUP_COUNTOF(out->lang), v); + } else if ((v = arg_value(tok, L"log")) != NULL) { + if (!copy_path_arg(out->log_path, SD_SETUP_COUNTOF(out->log_path), v)) + goto bad_token; + } else if ((v = arg_value(tok, L"extract")) != NULL) { + if (!copy_path_arg(out->extract_dir, SD_SETUP_COUNTOF(out->extract_dir), v)) + goto bad_token; + } else if ((v = arg_value(tok, L"waitpid")) != NULL) { + unsigned long pid = 0; + const wchar_t *d = v; + if (*d == L'\0') + goto bad_token; + for (; *d != L'\0'; d++) { + if (*d < L'0' || *d > L'9') + goto bad_token; + /* 4294967295 is the largest value of unsigned long: reject anything above it. */ + if (pid > 429496729ul || (pid == 429496729ul && *d > L'5')) + goto bad_token; + pid = pid * 10 + (unsigned long)(*d - L'0'); + } + out->wait_pid = pid; + } else if ((v = arg_value(tok, L"installdir")) != NULL) { + if (!copy_path_arg(out->install_dir, SD_SETUP_COUNTOF(out->install_dir), v)) + goto bad_token; + } else { + /* Unknown switch, or an elevation stage this build does not implement (04 §6.4). */ + goto bad_token; + } + continue; + + bad_token: + if (bad != NULL) + *bad = tok; + return SD_SETUP_EXIT_BADARGS; + } + + if (out->quiet && out->passive) + out->passive = 0; + return 0; +} + +/* ------------------------------------------------------------------ host and flavour */ + +#ifndef IMAGE_FILE_MACHINE_I386 +#define IMAGE_FILE_MACHINE_I386 0x014c +#endif +#ifndef IMAGE_FILE_MACHINE_AMD64 +#define IMAGE_FILE_MACHINE_AMD64 0x8664 +#endif +#ifndef IMAGE_FILE_MACHINE_ARM64 +#define IMAGE_FILE_MACHINE_ARM64 0xAA64 +#endif +#ifndef IMAGE_FILE_MACHINE_ARMNT +#define IMAGE_FILE_MACHINE_ARMNT 0x01c4 +#endif + +typedef BOOL (WINAPI *sd_IsWow64Process2_t)(HANDLE, USHORT *, USHORT *); +typedef BOOL (WINAPI *sd_IsWow64Process_t)(HANDLE, BOOL *); +typedef void (WINAPI *sd_GetNativeSystemInfo_t)(SYSTEM_INFO *); +typedef LONG (WINAPI *sd_RtlGetVersion_t)(OSVERSIONINFOW *); +typedef BOOL (WINAPI *sd_CheckTokenMembership_t)(HANDLE, PSID, PBOOL); +typedef BOOL (WINAPI *sd_AllocateAndInitializeSid_t)(PSID_IDENTIFIER_AUTHORITY, BYTE, DWORD, DWORD, DWORD, + DWORD, DWORD, DWORD, DWORD, DWORD, PSID *); +typedef void *(WINAPI *sd_FreeSid_t)(PSID); + +static int detect_is_admin(void) +{ + sd_CheckTokenMembership_t check = (sd_CheckTokenMembership_t)sd_setup_proc(L"advapi32.dll", "CheckTokenMembership"); + sd_AllocateAndInitializeSid_t alloc = + (sd_AllocateAndInitializeSid_t)sd_setup_proc(L"advapi32.dll", "AllocateAndInitializeSid"); + sd_FreeSid_t freesid = (sd_FreeSid_t)sd_setup_proc(L"advapi32.dll", "FreeSid"); + SID_IDENTIFIER_AUTHORITY nt = SECURITY_NT_AUTHORITY; + PSID admins = NULL; + BOOL member = FALSE; + int result = 0; + + if (check == NULL || alloc == NULL || freesid == NULL) + return 0; + if (!alloc(&nt, 2, SECURITY_BUILTIN_DOMAIN_RID, DOMAIN_ALIAS_RID_ADMINS, 0, 0, 0, 0, 0, 0, &admins)) + return 0; + if (check(NULL, admins, &member) && member) + result = 1; + freesid(admins); + return result; +} + +void sd_setup_detect_host(sd_setup_host *h) +{ + sd_IsWow64Process2_t wow2; + sd_IsWow64Process_t wow; + sd_GetNativeSystemInfo_t gnsi; + sd_RtlGetVersion_t rtl; + OSVERSIONINFOW ver; + + memset(h, 0, sizeof(*h)); + h->native_machine = 0; + + wow2 = (sd_IsWow64Process2_t)sd_setup_proc(L"kernel32.dll", "IsWow64Process2"); + if (wow2 != NULL) { + USHORT process_machine = 0, native_machine = 0; + if (wow2(GetCurrentProcess(), &process_machine, &native_machine) && native_machine != 0) + h->native_machine = (WORD)native_machine; + } + if (h->native_machine == 0) { + BOOL is_wow = FALSE; + wow = (sd_IsWow64Process_t)sd_setup_proc(L"kernel32.dll", "IsWow64Process"); + if (wow == NULL || !wow(GetCurrentProcess(), &is_wow) || !is_wow) { +#if defined(_M_ARM) + /* A 32-bit process on a 32-bit ARM OS: the ARM32 stub (04 §5.1). */ + h->native_machine = IMAGE_FILE_MACHINE_ARMNT; +#else + /* A 32-bit process on a 32-bit OS (this stub is always x86 code). */ + h->native_machine = IMAGE_FILE_MACHINE_I386; +#endif + } else { + gnsi = (sd_GetNativeSystemInfo_t)sd_setup_proc(L"kernel32.dll", "GetNativeSystemInfo"); + if (gnsi != NULL) { + SYSTEM_INFO si; + memset(&si, 0, sizeof(si)); + gnsi(&si); + if (si.wProcessorArchitecture == PROCESSOR_ARCHITECTURE_AMD64) + h->native_machine = IMAGE_FILE_MACHINE_AMD64; + else if (si.wProcessorArchitecture == PROCESSOR_ARCHITECTURE_ARM64) + h->native_machine = IMAGE_FILE_MACHINE_ARM64; + } + } + } + + h->sse2 = IsProcessorFeaturePresent(PF_XMMI64_INSTRUCTIONS_AVAILABLE) ? 1 : 0; + + rtl = (sd_RtlGetVersion_t)sd_setup_proc(L"ntdll.dll", "RtlGetVersion"); + memset(&ver, 0, sizeof(ver)); + ver.dwOSVersionInfoSize = sizeof(ver); + if (rtl != NULL && rtl(&ver) == 0) { + h->os_major = ver.dwMajorVersion; + h->os_minor = ver.dwMinorVersion; + } else { + /* Pre-NT5 fallback: never reached on XP+, kept so that a missing export cannot pick a wrong flavour. */ + h->os_major = 5; + h->os_minor = 1; + } + h->is_admin = detect_is_admin(); +} + +int sd_setup_select_flavour(const sd_setup_host *h, sd_setup_flavour override, sd_setup_flavour *out, + sd_msg_id *reason) +{ +#if defined(_M_ARM) + /* The ARM32 stub is ARM32 code (04 §1.1, §6.2). Every ARM host runs it, natively or emulated on ARM64, and the + only flavour it installs is arm32. The universal x86 stub never takes this path. */ + if (h->native_machine != IMAGE_FILE_MACHINE_ARMNT && h->native_machine != IMAGE_FILE_MACHINE_ARM64) { + *reason = SD_MSG_INSTALLER_ERROR_FLAVOUR_CPU; + return 1; + } + if (override != SD_SETUP_FL_NONE && override != SD_SETUP_FL_ARM32) { + *reason = SD_MSG_INSTALLER_ERROR_FLAVOUR_BITNESS; + return 1; + } + *out = SD_SETUP_FL_ARM32; + return 0; +#else + sd_setup_flavour base = SD_SETUP_FL_NONE; + + switch (h->native_machine) { + case IMAGE_FILE_MACHINE_I386: + base = h->sse2 ? SD_SETUP_FL_X86_SSE2 : SD_SETUP_FL_X86_IA32; + if (override == SD_SETUP_FL_NONE || override == SD_SETUP_FL_X86_IA32) { + if (override == SD_SETUP_FL_X86_IA32) + base = SD_SETUP_FL_X86_IA32; + } else if (override == SD_SETUP_FL_X86_SSE2) { + if (!h->sse2) { + *reason = SD_MSG_INSTALLER_ERROR_FLAVOUR_SSE2; + return 1; + } + base = SD_SETUP_FL_X86_SSE2; + } else { + *reason = SD_MSG_INSTALLER_ERROR_FLAVOUR_BITNESS; + return 1; + } + break; + + case IMAGE_FILE_MACHINE_AMD64: + base = (h->os_major >= 10) ? SD_SETUP_FL_X64_MODERN : SD_SETUP_FL_X64_XP; + if (override == SD_SETUP_FL_NONE) { + /* base as chosen above */ + } else if (override == SD_SETUP_FL_X64_XP) { + base = SD_SETUP_FL_X64_XP; + } else if (override == SD_SETUP_FL_X64_MODERN) { + if (h->os_major < 10) { + *reason = SD_MSG_INSTALLER_ERROR_FLAVOUR_OS; + return 1; + } + base = SD_SETUP_FL_X64_MODERN; + } else { + *reason = SD_MSG_INSTALLER_ERROR_FLAVOUR_BITNESS; + return 1; + } + break; + + case IMAGE_FILE_MACHINE_ARM64: + if (override != SD_SETUP_FL_NONE && override != SD_SETUP_FL_ARM64) { + *reason = SD_MSG_INSTALLER_ERROR_FLAVOUR_BITNESS; + return 1; + } + base = SD_SETUP_FL_ARM64; + break; + + case IMAGE_FILE_MACHINE_ARMNT: + *reason = SD_MSG_INSTALLER_ERROR_FLAVOUR_ARM32; + return 1; + + default: + *reason = SD_MSG_INSTALLER_ERROR_FLAVOUR_CPU; + return 1; + } + + *out = base; + return 0; +#endif +} + +/* ------------------------------------------------------------------ Dokany and versions */ + +sd_setup_dokany_plan sd_setup_dokany_decide(sd_setup_mode mode, int dokany_set, sd_setup_dokany_arg arg, + unsigned os_major, unsigned os_minor) +{ + int driver_ok = (os_major > 6) || (os_major == 6 && os_minor >= 1); + + /* Per-user installs never install the driver (04 §6.3). */ + if (mode != SD_SETUP_MODE_MACHINE) + return SD_SETUP_DOKANY_SKIP_PLAN; + if (dokany_set && arg == SD_SETUP_DOKANY_SKIP) + return SD_SETUP_DOKANY_SKIP_PLAN; + /* Pre-checked default (D36): install when the OS can take the driver (7 SP1+ per 04 §6.3). */ + if (!driver_ok) + return SD_SETUP_DOKANY_UNAVAILABLE_PLAN; + return SD_SETUP_DOKANY_INSTALL_PLAN; +} + +static int parse_version_part(const wchar_t **p, unsigned long *out) +{ + unsigned long v = 0; + int any = 0; + while (**p >= L'0' && **p <= L'9') { + v = v * 10 + (unsigned long)(**p - L'0'); + (*p)++; + any = 1; + if (v > 0xFFFFFFFFul / 10) + v = 0xFFFFFFFFul; + } + *out = any ? v : 0; + return any; +} + +int sd_setup_version_compare(const wchar_t *a, const wchar_t *b) +{ + const wchar_t *pa = a ? a : L""; + const wchar_t *pb = b ? b : L""; + int i; + + for (i = 0; i < 4; i++) { + unsigned long va = 0, vb = 0; + parse_version_part(&pa, &va); + parse_version_part(&pb, &vb); + if (va != vb) + return va < vb ? -1 : 1; + if (*pa == L'.') + pa++; + if (*pb == L'.') + pb++; + } + return 0; +} + +/* ------------------------------------------------------------------ payload table and CAB names */ + +static int hex_nibble(char c) +{ + if (c >= '0' && c <= '9') + return c - '0'; + if (c >= 'a' && c <= 'f') + return c - 'a' + 10; + if (c >= 'A' && c <= 'F') + return c - 'A' + 10; + return -1; +} + +/* One line per flavour: " ". Lines that do not name the flavour are skipped. */ +int sd_setup_payload_table_lookup(const char *table, size_t n, const wchar_t *flavour, unsigned char sha[32]) +{ + size_t i = 0; + size_t flen; + + if (table == NULL || flavour == NULL) + return 1; + flen = wcslen(flavour); + + while (i < n) { + size_t tok = 0; + int match = 1; + int j; + + /* Field 1: the flavour name, compared character by character. */ + while (i < n && table[i] != ' ' && table[i] != '\t' && table[i] != '\r' && table[i] != '\n') { + if (tok >= flen || (wchar_t)(unsigned char)table[i] != flavour[tok]) + match = 0; + tok++; + i++; + } + if (tok != flen) + match = 0; + + /* Field 2: 64 hex digits. */ + while (i < n && (table[i] == ' ' || table[i] == '\t')) + i++; + if (match && i + 64 <= n) { + for (j = 0; j < 32; j++) { + int hi = hex_nibble(table[i + 2 * j]); + int lo = hex_nibble(table[i + 2 * j + 1]); + if (hi < 0 || lo < 0) { + match = 0; + break; + } + sha[j] = (unsigned char)((hi << 4) | lo); + } + if (match) + return 0; + } + + /* Skip to the end of this line. */ + while (i < n && table[i] != '\n') + i++; + if (i < n) + i++; + } + return 1; +} + +void sd_setup_cab_limits_default(sd_setup_cab_limits *l) +{ + l->max_member = 512ull * 1024 * 1024; + l->max_total = 1536ull * 1024 * 1024; + l->max_members = 4096; +} + +static int is_reserved_device(const char *comp, size_t len) +{ + static const char *const names[] = { + "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", + "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9" + }; + size_t i; + for (i = 0; i < SD_SETUP_COUNTOF(names); i++) { + size_t nl = strlen(names[i]); + size_t k; + if (len < nl) + continue; + for (k = 0; k < nl; k++) { + if (toupper((unsigned char)comp[k]) != names[i][k]) + break; + } + if (k == nl && (len == nl || comp[nl] == '.')) + return 1; + } + return 0; +} + +int sd_setup_cab_name_ok(const char *name) +{ + size_t len, i, comp_start = 0; + + if (name == NULL) + return 0; + len = strlen(name); + if (len == 0 || len >= MAX_PATH) + return 0; + if (name[0] == '\\' || name[0] == '/') + return 0; + if (len >= 2 && name[1] == ':') + return 0; + + for (i = 0; i <= len; i++) { + unsigned char c = (unsigned char)name[i]; + if (i == len || c == '\\') { + size_t clen = i - comp_start; + if (clen == 0) + return 0; /* empty component: "a\\\\b" or trailing separator */ + if (clen == 1 && name[comp_start] == '.') + return 0; + if (clen == 2 && name[comp_start] == '.' && name[comp_start + 1] == '.') + return 0; /* traversal */ + if (name[i - 1] == '.' || name[i - 1] == ' ') + return 0; /* Windows strips trailing dots and spaces */ + if (is_reserved_device(name + comp_start, clen)) + return 0; + comp_start = i + 1; + continue; + } + if (c >= 0x80 || c < 0x20) + return 0; + if (c == '/' || c == ':' || c == '*' || c == '?' || c == '"' || c == '<' || c == '>' || c == '|') + return 0; + } + return 1; +} diff --git a/apps/shelldrive-setup/setup_cab.c b/apps/shelldrive-setup/setup_cab.c new file mode 100644 index 0000000..9a7009e --- /dev/null +++ b/apps/shelldrive-setup/setup_cab.c @@ -0,0 +1,337 @@ +/* shelldrive-setup: CAB payload extraction through the FDI API of cabinet.dll (04 §6.1). + + cabinet.dll is not a KnownDLL, so it is loaded at run time by its full System32 path and its functions + are resolved with GetProcAddress (04 §2.4, §6.4 rule 3). The payload is read from memory (the resource + of the stub or a buffer in tests), so the CAB never touches the disk. Every member is checked before a + file is created: the name (sd_setup_cab_name_ok, and the final full path must stay under the + destination), the member size and the totals. Members are created with CREATE_NEW. */ +#include "setup_int.h" + +#pragma warning(push) +#pragma warning(disable : 4201) /* the SDK 7.1A fdi.h has a nameless struct (W4) */ +#include +#pragma warning(pop) + +#include + +typedef HFDI (DIAMONDAPI *sd_FDICreate_t)(PFNALLOC, PFNFREE, PFNOPEN, PFNREAD, PFNWRITE, PFNCLOSE, PFNSEEK, + int, PERF); +typedef BOOL (DIAMONDAPI *sd_FDICopy_t)(HFDI, char *, char *, int, PFNFDINOTIFY, PFNFDIDECRYPT, void *); +typedef BOOL (DIAMONDAPI *sd_FDIDestroy_t)(HFDI); + +/* In-memory CAB: the only file FDI is allowed to open. */ +typedef struct cab_mem { + const unsigned char *data; + size_t size; + size_t pos; +} cab_mem; + +#define CAB_MEM_NAME "payload.cab" + +/* State of one extraction. FDI passes it to every notification as pfdin->pv. */ +typedef struct cab_ctx { + const wchar_t *dest; /* full path, no trailing backslash */ + sd_setup_cab_limits lim; + unsigned long long total; + unsigned members; + unsigned failed_msg; /* sd_msg_id as unsigned, 0 = none */ + wchar_t failed_text[MAX_PATH]; + long failed_code; + int fdi_error; + int multi; +} cab_ctx; + +/* FDI callbacks take no context, so the single active extraction is reached through these statics. + The engine runs one extraction at a time (no threads), which the API documents. */ +static cab_mem *g_cab_mem; + +/* FDI opens the cabinet more than once while it decodes a folder. Each open gets its own read position + (a shared one makes the second reader start at the end of the payload, and FDI reports a corrupt + cabinet). The open handles are tracked here so that pfnclose frees only those. */ +#define SD_CAB_MAX_OPENS 8 +static cab_mem *g_cab_opens[SD_CAB_MAX_OPENS]; + +static void *DIAMONDAPI cab_alloc(ULONG cb) +{ + return HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, cb); +} + +static void DIAMONDAPI cab_free(void *p) +{ + if (p != NULL) + HeapFree(GetProcessHeap(), 0, p); +} + +static INT_PTR DIAMONDAPI cab_open(char *name, int oflag, int pmode) +{ + size_t i; + cab_mem *h; + + (void)oflag; + (void)pmode; + if (g_cab_mem == NULL || strcmp(name, CAB_MEM_NAME) != 0) + return -1; /* nothing else is ever opened: no file system access by FDI */ + for (i = 0; i < SD_CAB_MAX_OPENS && g_cab_opens[i] != NULL; i++) + ; + if (i == SD_CAB_MAX_OPENS) + return -1; + h = (cab_mem *)HeapAlloc(GetProcessHeap(), 0, sizeof(*h)); + if (h == NULL) + return -1; + h->data = g_cab_mem->data; + h->size = g_cab_mem->size; + h->pos = 0; + g_cab_opens[i] = h; + return (INT_PTR)h; +} + +static UINT DIAMONDAPI cab_read(INT_PTR hf, void *buf, UINT cb) +{ + cab_mem *m = (cab_mem *)hf; + size_t left = m->size - m->pos; + size_t n = (size_t)cb < left ? (size_t)cb : left; + memcpy(buf, m->data + m->pos, n); + m->pos += n; + return (UINT)n; +} + +static UINT DIAMONDAPI cab_write(INT_PTR hf, void *buf, UINT cb) +{ + DWORD written = 0; + if (!WriteFile((HANDLE)hf, buf, cb, &written, NULL)) + return 0; + return (UINT)written; +} + +static int DIAMONDAPI cab_close(INT_PTR hf) +{ + size_t i; + for (i = 0; i < SD_CAB_MAX_OPENS; i++) { + if (g_cab_opens[i] != NULL && (INT_PTR)g_cab_opens[i] == hf) { + HeapFree(GetProcessHeap(), 0, g_cab_opens[i]); + g_cab_opens[i] = NULL; + return 0; + } + } + return CloseHandle((HANDLE)hf) ? 0 : -1; +} + +static long DIAMONDAPI cab_seek(INT_PTR hf, long dist, int seektype) +{ + cab_mem *m = (cab_mem *)hf; + long long base; + long long np; + + if (seektype == SEEK_SET) + base = 0; + else if (seektype == SEEK_CUR) + base = (long long)m->pos; + else + base = (long long)m->size; + np = base + dist; + if (np < 0 || np > (long long)m->size) + return -1; + m->pos = (size_t)np; + return (long)np; +} + +/* Creates each missing folder of the member's relative directory under dest. */ +static int make_member_dirs(const wchar_t *dest, const wchar_t *rel) +{ + wchar_t path[MAX_PATH]; + const wchar_t *p = rel; + + if (FAILED(StringCchCopyW(path, MAX_PATH, dest))) + return 0; + while (1) { + const wchar_t *slash = wcschr(p, L'\\'); + size_t len; + if (slash == NULL) + break; + len = slash - rel; + { + size_t cur = wcslen(path); + if (cur + 1 + len >= MAX_PATH) + return 0; + path[cur] = L'\\'; + memcpy(path + cur + 1, rel, len * sizeof(wchar_t)); + path[cur + 1 + len] = L'\0'; + } + if (!CreateDirectoryW(path, NULL) && GetLastError() != ERROR_ALREADY_EXISTS) + return 0; + p = slash + 1; + rel = p; + } + return 1; +} + +/* True when the full path of target starts with the full path of dest followed by a backslash. */ +static int path_is_under(const wchar_t *dest, const wchar_t *target) +{ + wchar_t fd[MAX_PATH], ft[MAX_PATH]; + DWORD n1 = GetFullPathNameW(dest, MAX_PATH, fd, NULL); + DWORD n2 = GetFullPathNameW(target, MAX_PATH, ft, NULL); + size_t dl; + + if (n1 == 0 || n1 >= MAX_PATH || n2 == 0 || n2 >= MAX_PATH) + return 0; + dl = wcslen(fd); + if (fd[dl - 1] == L'\\') + dl--; + return _wcsnicmp(fd, ft, dl) == 0 && ft[dl] == L'\\'; +} + +static INT_PTR DIAMONDAPI cab_notify(FDINOTIFICATIONTYPE fdint, PFDINOTIFICATION pfdin) +{ + cab_ctx *ctx = (cab_ctx *)pfdin->pv; + wchar_t wname[MAX_PATH]; + wchar_t target[MAX_PATH]; + HANDLE h; + + switch (fdint) { + case fdintCABINET_INFO: + return 0; + + case fdintNEXT_CABINET: + ctx->multi = 1; + return -1; + + case fdintPARTIAL_FILE: + return -1; + + case fdintCOPY_FILE: + if (!sd_setup_cab_name_ok(pfdin->psz1)) { + sd_setup_ascii_to_wide(pfdin->psz1, ctx->failed_text, SD_SETUP_COUNTOF(ctx->failed_text)); + ctx->failed_msg = SD_MSG_INSTALLER_ERROR_PAYLOAD_UNSAFE; + return -1; + } + if (ctx->members >= ctx->lim.max_members || (unsigned long long)pfdin->cb > ctx->lim.max_member || + ctx->total + (unsigned long long)pfdin->cb > ctx->lim.max_total) { + ctx->failed_msg = SD_MSG_INSTALLER_ERROR_PAYLOAD_TOO_BIG; + return -1; + } + if (sd_setup_ascii_to_wide(pfdin->psz1, wname, SD_SETUP_COUNTOF(wname)) != 0) { + ctx->failed_msg = SD_MSG_INSTALLER_ERROR_PAYLOAD_UNSAFE; + return -1; + } + if (!make_member_dirs(ctx->dest, wname)) { + ctx->failed_msg = SD_MSG_INSTALLER_ERROR_PAYLOAD_EXTRACT; + ctx->failed_code = (long)GetLastError(); + return -1; + } + if (sd_setup_path_join(target, MAX_PATH, ctx->dest, wname) != 0 || !path_is_under(ctx->dest, target)) { + ctx->failed_msg = SD_MSG_INSTALLER_ERROR_PAYLOAD_UNSAFE; + sd_setup_ascii_to_wide(pfdin->psz1, ctx->failed_text, SD_SETUP_COUNTOF(ctx->failed_text)); + return -1; + } + h = CreateFileW(target, GENERIC_WRITE, 0, NULL, CREATE_NEW, FILE_ATTRIBUTE_NORMAL, NULL); + if (h == INVALID_HANDLE_VALUE) { + ctx->failed_msg = SD_MSG_INSTALLER_ERROR_WRITE; + StringCchCopyW(ctx->failed_text, SD_SETUP_COUNTOF(ctx->failed_text), target); + ctx->failed_code = (long)GetLastError(); + return -1; + } + ctx->members++; + ctx->total += (unsigned long long)pfdin->cb; + return (INT_PTR)h; + + case fdintCLOSE_FILE_INFO: + if (!CloseHandle((HANDLE)pfdin->hf)) { + ctx->failed_msg = SD_MSG_INSTALLER_ERROR_WRITE; + ctx->failed_code = (long)GetLastError(); + return 0; + } + return 1; + + default: + return 0; + } +} + +/* Loads cabinet.dll from System32 and resolves the FDI entry points (cached per process). */ +static int load_fdi(sd_FDICreate_t *create, sd_FDICopy_t *copy, sd_FDIDestroy_t *destroy) +{ + *create = (sd_FDICreate_t)sd_setup_proc(L"cabinet.dll", "FDICreate"); + *copy = (sd_FDICopy_t)sd_setup_proc(L"cabinet.dll", "FDICopy"); + *destroy = (sd_FDIDestroy_t)sd_setup_proc(L"cabinet.dll", "FDIDestroy"); + return *create != NULL && *copy != NULL && *destroy != NULL; +} + +int sd_setup_cab_extract(const sd_setup_payload *p, const wchar_t *dest, const sd_setup_cab_limits *lim, + sd_setup_result *r, unsigned *files_out) +{ + sd_FDICreate_t fdi_create; + sd_FDICopy_t fdi_copy; + sd_FDIDestroy_t fdi_destroy; + cab_mem mem; + cab_ctx ctx; + ERF erf; + HFDI hfdi; + char cabname[] = CAB_MEM_NAME; + char cabpath[] = ""; + BOOL ok; + DWORD fattr; + + if (files_out != NULL) + *files_out = 0; + if (p == NULL || p->data == NULL || p->size == 0 || dest == NULL) + return sd_setup_fail(r, SD_SETUP_EXIT_FATAL, SD_MSG_INSTALLER_ERROR_PAYLOAD_DAMAGED, 0); + + fattr = GetFileAttributesW(dest); + if (fattr == INVALID_FILE_ATTRIBUTES || (fattr & FILE_ATTRIBUTE_DIRECTORY) == 0) + return sd_setup_fail(r, SD_SETUP_EXIT_FATAL, SD_MSG_INSTALLER_ERROR_WRITE, 0); + + if (!load_fdi(&fdi_create, &fdi_copy, &fdi_destroy)) + return sd_setup_fail(r, SD_SETUP_EXIT_FATAL, SD_MSG_INSTALLER_ERROR_PAYLOAD_EXTRACT, (long)GetLastError()); + + memset(&ctx, 0, sizeof(ctx)); + ctx.dest = dest; + if (lim != NULL) + ctx.lim = *lim; + else + sd_setup_cab_limits_default(&ctx.lim); + + mem.data = p->data; + mem.size = p->size; + mem.pos = 0; + + memset(&erf, 0, sizeof(erf)); + hfdi = fdi_create(cab_alloc, cab_free, cab_open, cab_read, cab_write, cab_close, cab_seek, cpuUNKNOWN, &erf); + if (hfdi == NULL) + return sd_setup_fail(r, SD_SETUP_EXIT_FATAL, SD_MSG_INSTALLER_ERROR_PAYLOAD_EXTRACT, (long)erf.erfOper); + + g_cab_mem = &mem; + memset(&erf, 0, sizeof(erf)); + ok = fdi_copy(hfdi, cabname, cabpath, 0, cab_notify, NULL, &ctx); + g_cab_mem = NULL; + fdi_destroy(hfdi); + { + size_t k; + for (k = 0; k < SD_CAB_MAX_OPENS; k++) { + if (g_cab_opens[k] != NULL) { + HeapFree(GetProcessHeap(), 0, g_cab_opens[k]); + g_cab_opens[k] = NULL; + } + } + } + + if (ctx.multi) + return sd_setup_fail(r, SD_SETUP_EXIT_FATAL, SD_MSG_INSTALLER_ERROR_PAYLOAD_DAMAGED, 0); + if (!ok || ctx.failed_msg != 0) { + if (ctx.failed_msg != 0) { + if (r != NULL) { + r->exit_code = SD_SETUP_EXIT_FATAL; + r->msg = (sd_msg_id)ctx.failed_msg; + StringCchCopyW(r->text, SD_SETUP_COUNTOF(r->text), ctx.failed_text); + r->code = ctx.failed_code; + } + sd_setup_log_error("payload.extract", ctx.failed_text, (DWORD)ctx.failed_code); + return SD_SETUP_EXIT_FATAL; + } + return sd_setup_fail(r, SD_SETUP_EXIT_FATAL, SD_MSG_INSTALLER_ERROR_PAYLOAD_EXTRACT, (long)erf.erfOper); + } + + if (files_out != NULL) + *files_out = ctx.members; + return 0; +} diff --git a/apps/shelldrive-setup/setup_core.h b/apps/shelldrive-setup/setup_core.h new file mode 100644 index 0000000..3056e77 --- /dev/null +++ b/apps/shelldrive-setup/setup_core.h @@ -0,0 +1,281 @@ +/* shelldrive-setup engine: public interface (docs/design/04 §6). + * + * The engine is a static library (sd_setup_core) used by the setup stub (shelldrive-setup.exe; its + * payload-less copy is uninstall.exe) and by tests/setup. It never shows UI itself: callers pass a + * progress callback and format messages with sd_setup_format_message(). Nothing here imports a DLL that + * is not a KnownDLL: cabinet.dll, the Toolhelp, Win8+ and ntdll entry points are resolved at run time + * (04 §2.4, §6.4). Strings are wide except CAB member names, which are ASCII. + * + * Exit codes follow the MSI conventions (04 §6.5). */ +#ifndef SD_SETUP_CORE_H +#define SD_SETUP_CORE_H + +#include +#include + +#include "sd_l10n/sd_l10n_catalog.h" + +#define SD_SETUP_EXIT_OK 0 +#define SD_SETUP_EXIT_REBOOT 3010 +#define SD_SETUP_EXIT_CANCEL 1602 +#define SD_SETUP_EXIT_FATAL 1603 +#define SD_SETUP_EXIT_NEWER 1638 +#define SD_SETUP_EXIT_BADARGS 1639 + +/* Build flavours the setup can choose (04 §1.1). ARM32 has its own setup program. */ +typedef enum sd_setup_flavour { + SD_SETUP_FL_NONE = 0, + SD_SETUP_FL_X86_IA32, + SD_SETUP_FL_X86_SSE2, + SD_SETUP_FL_X64_XP, + SD_SETUP_FL_X64_MODERN, + SD_SETUP_FL_ARM64, + SD_SETUP_FL_ARM32 +} sd_setup_flavour; + +const wchar_t *sd_setup_flavour_name(sd_setup_flavour f); /* "x86-ia32", ... or NULL */ +sd_setup_flavour sd_setup_flavour_parse(const wchar_t *name); /* SD_SETUP_FL_NONE if unknown */ +const wchar_t *sd_setup_payload_resource(sd_setup_flavour f); /* "PAYLOAD_X86_IA32", ... or NULL */ + +typedef enum sd_setup_mode { + SD_SETUP_MODE_AUTO = 0, /* no mode given: per-user in this build (open item, see 04 §6.3) */ + SD_SETUP_MODE_USER, + SD_SETUP_MODE_MACHINE, + SD_SETUP_MODE_PORTABLE, + SD_SETUP_MODE_UNINSTALL +} sd_setup_mode; + +typedef enum sd_setup_dokany_arg { + SD_SETUP_DOKANY_AUTO = 0, /* /dokany=auto: the pre-checked option (D36) */ + SD_SETUP_DOKANY_INSTALL, /* /dokany=install: also upgrade (04 §7.3) */ + SD_SETUP_DOKANY_SKIP /* /dokany=skip: WebDAV only */ +} sd_setup_dokany_arg; + +/* Parsed command line. Strings are copied, so argv may go away. */ +typedef struct sd_setup_args { + sd_setup_mode mode; + int quiet; + int passive; + int norestart; + int force; + int update; + int help; + int test_pages; /* hidden: /test-pages builds the wizard pages and installs nothing (tests) */ + int autostart; /* -1 unset, 0 or 1 */ + int flavour_forced; + sd_setup_flavour flavour; + int dokany_set; + sd_setup_dokany_arg dokany; + int uninstall_relaunched; /* internal: /uninstall-from-temp, the copy in %TEMP% */ + unsigned long wait_pid; /* internal: /waitpid=, the parent uninstaller to wait for */ + int elevate_stage; /* internal (04 §6.4): 1 = /elevated-stage1, 2 = /elevated-child; 0 otherwise */ + wchar_t pipe_name[64]; /* internal: /pipe=ShellDrive-setup-, the progress pipe of the elevated stages */ + wchar_t lang[16]; + wchar_t log_path[MAX_PATH]; + wchar_t portable_dir[MAX_PATH]; + wchar_t extract_dir[MAX_PATH]; + wchar_t install_dir[MAX_PATH]; /* /installdir= (internal, tests) */ + unsigned properties; /* number of PROPERTY=value tokens */ + wchar_t props[1024]; /* the PROPERTY=value tokens, each quoted, for the msiexec command line (04 §8.4) */ +} sd_setup_args; + +/* 0, or SD_SETUP_EXIT_BADARGS with *bad pointing at the offending argv element. argv[0] is skipped. */ +int sd_setup_parse_args(int argc, const wchar_t *const *argv, sd_setup_args *out, const wchar_t **bad); + +/* Host facts for the flavour choice (04 §6.2). */ +typedef struct sd_setup_host { + WORD native_machine; /* IMAGE_FILE_MACHINE_I386 / AMD64 / ARM64 / ARMNT; 0 = unknown */ + int sse2; /* IsProcessorFeaturePresent(PF_XMMI64_INSTRUCTIONS_AVAILABLE) */ + unsigned os_major; /* RtlGetVersion */ + unsigned os_minor; + int is_admin; +} sd_setup_host; + +void sd_setup_detect_host(sd_setup_host *h); + +/* 0 with *out set when the flavour fits the host. Otherwise 1 with *reason set to a message id; the + message takes {flavour} when the caller also supplies the requested name. A forced override is + refused when the host cannot run it (04 §6.2). */ +int sd_setup_select_flavour(const sd_setup_host *h, sd_setup_flavour override, sd_setup_flavour *out, + sd_msg_id *reason); + +/* Dokany decision (04 §6.3, §7.3). Per-user installs never install Dokany. */ +typedef enum sd_setup_dokany_plan { + SD_SETUP_DOKANY_SKIP_PLAN = 0, + SD_SETUP_DOKANY_INSTALL_PLAN, + SD_SETUP_DOKANY_UNAVAILABLE_PLAN +} sd_setup_dokany_plan; + +sd_setup_dokany_plan sd_setup_dokany_decide(sd_setup_mode mode, int dokany_set, sd_setup_dokany_arg arg, + unsigned os_major, unsigned os_minor); + +/* Dotted numeric versions ("1.2.3.4"), compared part by part. Returns <0, 0 or >0. */ +int sd_setup_version_compare(const wchar_t *a, const wchar_t *b); + +/* Outcome of an operation. The message fields fill the placeholders of sd_setup_format_message(). */ +typedef struct sd_setup_result { + int exit_code; + sd_msg_id msg; /* message for the user; 0 when none */ + wchar_t text[MAX_PATH * 2]; /* {flavour}, {name}, {option}, {file}, {path} or {folder} (see format) */ + wchar_t folder[MAX_PATH]; /* {folder} and the install folder */ + wchar_t version[64]; /* {version} */ + wchar_t log_path[MAX_PATH]; /* {path} of installer.log.saved */ + long code; /* {code} */ + unsigned count; /* {count} of installer.summary.files */ + int warn_volume; /* installer.warn.volume applies */ +} sd_setup_result; + +/* Formats the message of r (msg, text and the fields above) into buf. Returns the length or -1. */ +int sd_setup_format_message(const sd_setup_result *r, sd_msg_id id, wchar_t *buf, size_t cch); + +/* Progress: stage is an installer.progress.* id and folder its {folder}, or NULL. */ +typedef void (*sd_setup_progress_fn)(void *ctx, sd_msg_id stage, const wchar_t *folder, unsigned percent); + +/* Payload source for an install: the CAB bytes (resource or file) and an optional expected SHA-256 + (from the payload table, 04 §6.1). */ +typedef struct sd_setup_payload { + const unsigned char *data; + size_t size; +} sd_setup_payload; + +typedef struct sd_setup_cab_limits { + unsigned long long max_member; /* bytes, per member */ + unsigned long long max_total; /* bytes, all members */ + unsigned max_members; +} sd_setup_cab_limits; + +void sd_setup_cab_limits_default(sd_setup_cab_limits *l); + +/* 1 when an ASCII CAB member name is a safe relative path: no drive, no leading separator, no "..", no + empty component, no character Windows rejects, no non-ASCII byte, not longer than MAX_PATH. */ +int sd_setup_cab_name_ok(const char *name); + +/* Extracts every member under dest (an existing folder). Unsafe names, oversize members or totals, and + multi-cabinet payloads abort: returns SD_SETUP_EXIT_FATAL with r->msg set. Members written before the + failure stay; the caller removes dest. r may be NULL. */ +int sd_setup_cab_extract(const sd_setup_payload *p, const wchar_t *dest, const sd_setup_cab_limits *lim, + sd_setup_result *r, unsigned *files_out); + +/* SHA-256 (FIPS 180-4), no dependencies: the stub cannot link sd_crypto (CNG imports). */ +void sd_setup_sha256(const void *data, size_t n, unsigned char out[32]); + +/* Looks up the flavour's line of the PAYLOAD_TABLE resource: " <64 hex digits> " per line. + Returns 0 and fills sha with the expected digest, or 1 when the flavour is not listed. */ +int sd_setup_payload_table_lookup(const char *table, size_t n, const wchar_t *flavour, unsigned char sha[32]); + +/* Shutdown events of running agents (04 §6.9 step 1, 03 §2.1). owner_sid NULL means the current user. + *signalled gets the number of events set. Returns 0 or SD_SETUP_EXIT_FATAL. */ +int sd_setup_signal_agents(const wchar_t *owner_sid, unsigned *signalled); + +/* Waits up to timeout_ms for the agents of the current user to exit (their processes), and returns the + number still running in *still. */ +int sd_setup_wait_agents(unsigned timeout_ms, unsigned *still); + +/* Registration rows (packaging/registration.txt, generated into sd_setup_regtable.c). */ +typedef struct sd_setup_regrow { + const wchar_t *subkey; + const wchar_t *name; /* L"" = default value */ + int is_dword; + const wchar_t *data; + int autostart; /* 1 = written only when sign-in start is chosen */ + int view; /* SD_SETUP_REGVIEW_* : which registry view the row belongs to */ +} sd_setup_regrow; +extern const sd_setup_regrow sd_setup_regtable[]; +extern const size_t sd_setup_regtable_count; + +/* Registry views of a row (04 §6.2, 03 §8.5). DEFAULT: the view of the setup process. SHELL: the shell extension of + the installed flavour (its bitness: sd_setup_regctx.shell_access). SHELL32: the x86 copy of the shell extension, + written only when sd_setup_regctx.install_dir32 is set. */ +#define SD_SETUP_REGVIEW_DEFAULT 0 +#define SD_SETUP_REGVIEW_SHELL 1 +#define SD_SETUP_REGVIEW_SHELL32 2 + +/* Write (remove = 0) or remove (remove = 1) the rows under HKCU or HKLM via the hive root given. + install_dir ends with a backslash; version and flavour fill the placeholders. + shell_access is KEY_WOW64_32KEY or KEY_WOW64_64KEY (0 = the setup process's own view): the view of the SHELL rows. + install_dir32 is the folder of the x86 shell extension ([INSTALLDIR32], ends with a backslash) or NULL: then the + SHELL32 rows are not written (they would otherwise replace the same CLSIDs in a view that is shared, 03 §8.5). */ +typedef struct sd_setup_regctx { + HKEY root; + const wchar_t *install_dir; + const wchar_t *version; + const wchar_t *flavour; + unsigned long long install_bytes; + int autostart; + REGSAM shell_access; + const wchar_t *install_dir32; +} sd_setup_regctx; + +int sd_setup_reg_apply(const sd_setup_regctx *c); +int sd_setup_reg_remove(const sd_setup_regctx *c); + +/* Per-user shell registration of the x86 copy (04 §6.2, 03 §8.5). sd_setup_views_split: 1 on x64 Windows XP and + Server 2003, where the 32-bit and 64-bit views of HKCU\Software\Classes are separate (XP x64); 0 on every other + system, where one HKCU\Software\Classes serves both views and a second registration would replace the 64-bit + CLSIDs. sd_setup_shell32_wanted: 1 when the flavour has an x86 copy (x64-xp, x64-modern) and the views are split. */ +int sd_setup_views_split(const sd_setup_host *h); +int sd_setup_shell32_wanted(sd_setup_flavour f, const sd_setup_host *h); + +/* Operations. Paths default when NULL: install folder = %LOCALAPPDATA%\Programs\LibreStatic\ShellDrive, + Start menu = Programs\ShellDrive.lnk. Writes the setup log to log_path when given ("" = none). */ +typedef struct sd_setup_install_opts { + sd_setup_mode mode; /* SD_SETUP_MODE_USER or SD_SETUP_MODE_PORTABLE */ + const wchar_t *install_dir; + const wchar_t *portable_dir; + const wchar_t *programs_dir; /* test seam for the Start menu folder */ + const wchar_t *version; + sd_setup_flavour flavour; + const sd_setup_payload *payload; + const unsigned char *expected_sha256; + int autostart; + int force; + int update; + int skip_shortcut; + int skip_hooks; + int skip_agents; /* tests only: never signal or wait for running agents */ + const wchar_t *log_path; + sd_setup_progress_fn progress; + void *progress_ctx; +} sd_setup_install_opts; + +int sd_setup_install(const sd_setup_install_opts *o, sd_setup_result *r); + +typedef struct sd_setup_uninstall_opts { + const wchar_t *install_dir; /* NULL = the InstallDir value of the account registration */ + const wchar_t *programs_dir; + int force; + int skip_shortcut; + int skip_hooks; + int skip_agents; /* tests only: never signal or wait for running agents */ + const wchar_t *log_path; + sd_setup_progress_fn progress; + void *progress_ctx; +} sd_setup_uninstall_opts; + +int sd_setup_uninstall(const sd_setup_uninstall_opts *o, sd_setup_result *r); + +/* Per-machine (MSI) installs, read from the machine registry in both registry views (a 32-bit process on x64 sees + Wow6432Node; the x64 package writes the 64-bit view). sd_setup_machine_product finds the Uninstall key of an MSI product + named ShellDrive by LibreStatic (WindowsInstaller = 1) and copies its braced product code: 1 when found. + sd_setup_machine_install_dir reads the InstallDir value of the core registration rows: 0 when found, with no trailing + backslash. Read-only. */ +int sd_setup_machine_product(wchar_t *code, size_t cch); +int sd_setup_machine_install_dir(wchar_t *out, size_t cch); + +/* Log file (04 §13): English lines, UTF-8. */ +int sd_setup_log_open(const wchar_t *path); +void sd_setup_log(const char *event, const wchar_t *text); +void sd_setup_log_close(void); +const wchar_t *sd_setup_log_path(void); + +/* Start menu shortcut (IShellLinkW, 04 §6.7). */ +int sd_setup_shortcut_create(const wchar_t *lnk, const wchar_t *target, const wchar_t *workdir, + const wchar_t *description, const wchar_t *icon); + +/* Current account SID as a string, for the shutdown event name. Returns 0 on success. */ +int sd_setup_current_sid(wchar_t *out, size_t cch); + +/* InstallDir of the account registration (no trailing backslash). Returns 0 when registered. */ +int sd_setup_registered_install_dir(wchar_t *out, size_t cch); + +#endif /* SD_SETUP_CORE_H */ diff --git a/apps/shelldrive-setup/setup_dokany.c b/apps/shelldrive-setup/setup_dokany.c new file mode 100644 index 0000000..02e2bd6 --- /dev/null +++ b/apps/shelldrive-setup/setup_dokany.c @@ -0,0 +1,229 @@ +/* shelldrive-setup: Dokany chaining decisions (docs/design/04 §7.3; C31, D32). See setup_dokany.h. */ +#include "setup_int.h" + +#include +#include + +#include "setup_dokany.h" + +#define SD_PE_NAME_MAX 64 +#define SD_PE_MAX_IMPORTS 4096 +#define SD_PE_MAX_SECTIONS 96 + +static int rd16(const unsigned char *img, size_t n, size_t off, DWORD *v) +{ + if (off > n || n - off < 2) + return 1; + *v = (DWORD)img[off] | ((DWORD)img[off + 1] << 8); + return 0; +} + +static int rd32(const unsigned char *img, size_t n, size_t off, DWORD *v) +{ + if (off > n || n - off < 4) + return 1; + *v = (DWORD)img[off] | ((DWORD)img[off + 1] << 8) | ((DWORD)img[off + 2] << 16) | ((DWORD)img[off + 3] << 24); + return 0; +} + +/* Maps an RVA to a file offset through the section table. The byte at the RVA must lie in the raw data of its section + (a zero-filled tail is not readable here), and need bytes must be inside the image. */ +static int rva_to_off(const unsigned char *img, size_t n, size_t sec_off, DWORD nsec, DWORD rva, size_t need, + size_t *off) +{ + DWORD i; + + for (i = 0; i < nsec; i++) { + size_t s = sec_off + (size_t)i * 40; + DWORD vsz; + DWORD va; + DWORD rsz; + DWORD raw; + DWORD span; + DWORD delta; + size_t o; + + if (rd32(img, n, s + 8, &vsz) || rd32(img, n, s + 12, &va) || rd32(img, n, s + 16, &rsz) || + rd32(img, n, s + 20, &raw)) + return 1; + span = vsz > rsz ? vsz : rsz; + if (rva < va || rva - va >= span) + continue; + delta = rva - va; + if (delta >= rsz) + return 1; + o = (size_t)raw + (size_t)delta; + if (o > n || n - o < need) + return 1; + *off = o; + return 0; + } + return 1; +} + +/* 1 when the name at off (NUL-terminated, ASCII, inside the image) is a VC++ 2015-2022 runtime module. */ +static int name_is_vcr(const unsigned char *img, size_t n, size_t off) +{ + char low[SD_PE_NAME_MAX]; + size_t k = 0; + + while (off + k < n && k < sizeof(low) - 1) { + unsigned char c = img[off + k]; + if (c == 0) + break; + if (c >= 'A' && c <= 'Z') + c = (unsigned char)(c + ('a' - 'A')); + low[k] = (char)c; + k++; + } + low[k] = '\0'; + return strncmp(low, "vcruntime140", 12) == 0 || strncmp(low, "msvcp140", 8) == 0; +} + +int sd_setup_pe_vcr_state(const unsigned char *img, size_t n, sd_setup_vcr_state *state) +{ + DWORD e_lfanew = 0; + DWORD nsec = 0; + DWORD optsz = 0; + DWORD magic = 0; + DWORD imp_rva = 0; + DWORD imp_size = 0; + size_t pe_off; + size_t opt_off; + size_t dd_off; + size_t sec_off; + size_t desc = 0; + int found = 0; + int done = 0; + DWORD i; + + if (state == NULL) + return 1; + *state = SD_SETUP_VCR_UNKNOWN; + if (img == NULL || n < 0x40 || img[0] != 'M' || img[1] != 'Z') + return 1; + if (rd32(img, n, 0x3c, &e_lfanew) || e_lfanew < 0x40 || (size_t)e_lfanew > n) + return 1; + pe_off = (size_t)e_lfanew; + if (n - pe_off < 24 || memcmp(img + pe_off, "PE\0\0", 4) != 0) + return 1; + if (rd16(img, n, pe_off + 6, &nsec) || rd16(img, n, pe_off + 20, &optsz)) + return 1; + if (nsec > SD_PE_MAX_SECTIONS) + return 1; + opt_off = pe_off + 24; + if (rd16(img, n, opt_off, &magic)) + return 1; + if (magic == 0x10B) + dd_off = opt_off + 96; /* PE32: DataDirectory after 28 + 68 bytes */ + else if (magic == 0x20B) + dd_off = opt_off + 112; /* PE32+ */ + else + return 1; + /* Import directory is entry 1 of the data directories. */ + if ((size_t)optsz < (dd_off - opt_off) + 16) + return 1; + if (rd32(img, n, dd_off + 8, &imp_rva) || rd32(img, n, dd_off + 12, &imp_size)) + return 1; + if (imp_rva == 0) + imp_size = 0; + if (imp_rva == 0 || imp_size == 0) { + *state = SD_SETUP_VCR_NOT_NEEDED; + return 0; + } + sec_off = opt_off + (size_t)optsz; + if (rva_to_off(img, n, sec_off, nsec, imp_rva, 20, &desc)) + return 1; + + /* IMAGE_IMPORT_DESCRIPTOR array, ended by an all-zero record (its Name RVA is zero). */ + for (i = 0; i < SD_PE_MAX_IMPORTS && !done; i++) { + size_t d = desc + (size_t)i * 20; + DWORD name_rva; + size_t no; + + if (d > n || n - d < 20) + return 1; + if (rd32(img, n, d + 12, &name_rva)) + return 1; + if (name_rva == 0) { + done = 1; + break; + } + if (rva_to_off(img, n, sec_off, nsec, name_rva, 1, &no)) + return 1; + if (name_is_vcr(img, n, no)) + found = 1; + } + if (!done) + return 1; + *state = found ? SD_SETUP_VCR_NEEDED : SD_SETUP_VCR_NOT_NEEDED; + return 0; +} + +sd_setup_dokany_chain sd_setup_dokany_choose(sd_setup_vcr_state vcr, int have_msi, int have_bundle) +{ + switch (vcr) { + case SD_SETUP_VCR_NOT_NEEDED: + return have_msi ? SD_SETUP_DOKANY_CHAIN_MSI : SD_SETUP_DOKANY_CHAIN_UNAVAILABLE; + case SD_SETUP_VCR_NEEDED: + return have_bundle ? SD_SETUP_DOKANY_CHAIN_BUNDLE : SD_SETUP_DOKANY_CHAIN_UNAVAILABLE; + default: + return SD_SETUP_DOKANY_CHAIN_UNAVAILABLE; + } +} + +int sd_setup_dokany_command(sd_setup_dokany_chain chain, const wchar_t *sysdir, const wchar_t *package, + const wchar_t *log, wchar_t *exe, size_t ecch, wchar_t *args, size_t acch) +{ + if (package == NULL || log == NULL || exe == NULL || args == NULL) + return 1; + if (wcschr(package, L'"') != NULL || wcschr(log, L'"') != NULL) + return 1; + if (chain == SD_SETUP_DOKANY_CHAIN_MSI) { + if (sysdir == NULL || sd_setup_path_join(exe, ecch, sysdir, L"msiexec.exe") != 0) + return 1; + return FAILED(StringCchPrintfW(args, acch, L"/i \"%s\" /qn /norestart /l*v \"%s\"", package, log)) ? 1 : 0; + } + if (chain == SD_SETUP_DOKANY_CHAIN_BUNDLE) { + if (FAILED(StringCchCopyW(exe, ecch, package))) + return 1; + return FAILED(StringCchPrintfW(args, acch, L"/install /quiet /norestart /log \"%s\"", log)) ? 1 : 0; + } + return 1; +} + +DWORD sd_setup_dokany_run(sd_setup_dokany_chain chain, const sd_setup_payload *package, + const unsigned char expected_sha256[32], const wchar_t *sysdir, const wchar_t *package_path, + const wchar_t *log, sd_setup_dokany_run_fn run, void *ctx, DWORD *exit_code) +{ + unsigned char sha[32]; + wchar_t exe[MAX_PATH * 2]; + wchar_t args[MAX_PATH * 4]; + DWORD code = 0; + DWORD err; + + if (exit_code != NULL) + *exit_code = 0; + if (chain != SD_SETUP_DOKANY_CHAIN_MSI && chain != SD_SETUP_DOKANY_CHAIN_BUNDLE) + return ERROR_NOT_SUPPORTED; + /* Without a runner nothing is started: the engine never installs Dokany on its own (C31 is a lab item). */ + if (run == NULL) + return ERROR_CALL_NOT_IMPLEMENTED; + if (package == NULL || package->data == NULL || expected_sha256 == NULL) + return ERROR_INVALID_PARAMETER; + /* The bytes are checked again here, immediately before they are run (TM-R23, T-65). */ + sd_setup_sha256(package->data, package->size, sha); + if (memcmp(sha, expected_sha256, sizeof(sha)) != 0) + return ERROR_INVALID_DATA; + if (sd_setup_dokany_command(chain, sysdir, package_path, log, exe, SD_SETUP_COUNTOF(exe), args, + SD_SETUP_COUNTOF(args)) != 0) + return ERROR_INVALID_PARAMETER; + err = run(ctx, exe, args, &code); + if (err != 0) + return err; + if (exit_code != NULL) + *exit_code = code; + if (code == 0 || code == 3010) + return 0; + return code; +} diff --git a/apps/shelldrive-setup/setup_dokany.h b/apps/shelldrive-setup/setup_dokany.h new file mode 100644 index 0000000..6172ac0 --- /dev/null +++ b/apps/shelldrive-setup/setup_dokany.h @@ -0,0 +1,56 @@ +/* shelldrive-setup: Dokany chaining (docs/design/04 §7.3; C31, D32). + + Decides how the Dokany driver package is chained after ShellDrive's own install: the upstream MSI when dokan2.dll + needs no VC++ runtime, the unmodified upstream DokanSetup.exe bundle when it does. The decision and the command lines + are pure. The only process start goes through a runner the caller injects; this build injects none, so the engine + can never install Dokany by itself (sd_setup_dokany_run refuses). */ +#ifndef SD_SETUP_DOKANY_H +#define SD_SETUP_DOKANY_H + +#include +#include + +#include "setup_core.h" + +/* VC++ dependency of dokan2.dll (C31): unknown when the image cannot be parsed (fails closed). */ +typedef enum sd_setup_vcr_state { + SD_SETUP_VCR_UNKNOWN = -1, + SD_SETUP_VCR_NOT_NEEDED = 0, + SD_SETUP_VCR_NEEDED = 1 +} sd_setup_vcr_state; + +/* Parses the PE image in img (a copy of dokan2.dll, read as bytes, never loaded) and reports whether its import table + names a VC++ 2015-2022 runtime module (vcruntime140*.dll or msvcp140*.dll, any case). Returns 0 and *state, or 1 + for a malformed image (*state is then SD_SETUP_VCR_UNKNOWN). Only the import directory is read; every offset and + length is checked against n. */ +int sd_setup_pe_vcr_state(const unsigned char *img, size_t n, sd_setup_vcr_state *state); + +typedef enum sd_setup_dokany_chain { + SD_SETUP_DOKANY_CHAIN_NONE = 0, /* nothing to chain: the choice was "skip" or the driver is already there */ + SD_SETUP_DOKANY_CHAIN_MSI, /* msiexec /i Dokan_.msi (no VC++ runtime needed) */ + SD_SETUP_DOKANY_CHAIN_BUNDLE, /* DokanSetup.exe bundle, unmodified (VC++ runtime needed) */ + SD_SETUP_DOKANY_CHAIN_UNAVAILABLE /* no suitable package, or the dependency is unknown: WebDAV only */ +} sd_setup_dokany_chain; + +/* have_msi and have_bundle say which packages are present in the payload; vcr is the VC++ state of dokan2.dll. */ +sd_setup_dokany_chain sd_setup_dokany_choose(sd_setup_vcr_state vcr, int have_msi, int have_bundle); + +/* Command line of a chain step. sysdir is the System32 folder (msiexec is taken from it, never from PATH); package is the + file of the chosen package; log is the install log. Returns 0, or 1 when the chain is NONE or UNAVAILABLE or a buffer + is too small. The MSI form is `msiexec /i "" /qn /norestart /l*v ""`; the bundle form is + `DokanSetup.exe /install /quiet /norestart /log ""` (its switches are [lab-verify], 04 §7.3). */ +int sd_setup_dokany_command(sd_setup_dokany_chain chain, const wchar_t *sysdir, const wchar_t *package, + const wchar_t *log, wchar_t *exe, size_t ecch, wchar_t *args, size_t acch); + +/* The process starter. It receives the program and its arguments and returns the Win32 error of the start, and the exit + code of the program in *exit_code. */ +typedef DWORD (*sd_setup_dokany_run_fn)(void *ctx, const wchar_t *exe, const wchar_t *args, DWORD *exit_code); + +/* Runs one chain step when the package bytes match expected_sha256 (checked again here, at install time, TM-R23). + run is required: with NULL the call returns ERROR_CALL_NOT_IMPLEMENTED and starts nothing. Returns 0 when the program + exited with 0 or 3010 (reboot needed), otherwise a Win32 error. */ +DWORD sd_setup_dokany_run(sd_setup_dokany_chain chain, const sd_setup_payload *package, + const unsigned char expected_sha256[32], const wchar_t *sysdir, const wchar_t *package_path, + const wchar_t *log, sd_setup_dokany_run_fn run, void *ctx, DWORD *exit_code); + +#endif /* SD_SETUP_DOKANY_H */ diff --git a/apps/shelldrive-setup/setup_elevate.c b/apps/shelldrive-setup/setup_elevate.c new file mode 100644 index 0000000..00594da --- /dev/null +++ b/apps/shelldrive-setup/setup_elevate.c @@ -0,0 +1,1238 @@ +/* shelldrive-setup: two-stage elevation for per-machine installs (docs/design/04 §6.4, TM-R33, TM-R34, T-62, T-63). + The stages are described in setup_elevate.h. + + Pipe protocol: one line per message, UTF-16, "P " for progress, "M " for the message stage 2 + wants shown, and "R " as the final line. The stub imports no CRT string search (04 §2.4, ISA audit), so the + line parsing below is written out. + + Stage 2 never trusts a path from the command line: it derives the stage folder from its own image, checks the folder and + its name, and only then extracts the payload and runs msiexec. Stage 1 extracts nothing. */ +#include "setup_int.h" + +#include +#include +#include +#include + +#include "setup_elevate.h" +#include "setup_trust.h" +#include "svc_repair.h" + +#define SD_ELEV_NAME_PREFIX L"ShellDrive-setup-" +#define SD_ELEV_NAME_PREFIX_LEN 17 +#define SD_ELEV_HEX_LEN 32 +#define SD_ELEV_STAGE_LEAF L"shelldrive-setup.exe" +#define SD_ELEV_PAYLOAD_DIR L"payload" +#define SD_ELEV_MSI_LEAF L"ShellDrive.msi" +#define SD_ELEV_MSI_LOG L"msi.log" +#define SD_ELEV_CMD_CCH 2048 +#define SD_ELEV_FWD_CCH 1024 + +/* Pipe DACL: the launching user's SID only (04 §6.4). */ +static const wchar_t *const k_pipe_sddl_fmt = L"D:P(A;;GRGW;;;%s)"; + +/* ------------------------------------------------------------------ names */ + +static int is_hex(wchar_t c) +{ + return (c >= L'0' && c <= L'9') || (c >= L'a' && c <= L'f'); +} + +static int has_prefix(const wchar_t *s, const wchar_t *prefix) +{ + while (*prefix != L'\0') { + if (*s != *prefix) + return 0; + s++; + prefix++; + } + return 1; +} + +int sd_setup_elev_pipe_name_ok(const wchar_t *name) +{ + size_t i; + if (name == NULL || wcslen(name) != SD_ELEV_NAME_PREFIX_LEN + SD_ELEV_HEX_LEN || !has_prefix(name, SD_ELEV_NAME_PREFIX)) + return 0; + for (i = SD_ELEV_NAME_PREFIX_LEN; name[i] != L'\0'; i++) { + if (!is_hex(name[i])) + return 0; + } + return 1; +} + +int sd_setup_elev_pipe_new_name(wchar_t *out, size_t cch) +{ + wchar_t hex[40]; + size_t i; + if (out == NULL || cch < SD_ELEV_NAME_PREFIX_LEN + SD_ELEV_HEX_LEN + 1) + return 1; + if (sd_setup_random_hex(hex, SD_SETUP_COUNTOF(hex)) != 0) + return 1; + /* The random hex is upper case; the pipe name uses lower case. */ + for (i = 0; i < SD_ELEV_HEX_LEN; i++) { + if (hex[i] >= L'A' && hex[i] <= L'F') + hex[i] = (wchar_t)(hex[i] + (L'a' - L'A')); + } + hex[SD_ELEV_HEX_LEN] = L'\0'; + StringCchPrintfW(out, cch, L"%s%s", SD_ELEV_NAME_PREFIX, hex); + return sd_setup_elev_pipe_name_ok(out) ? 0 : 1; +} + +int sd_setup_elev_root(const wchar_t *base, wchar_t *out, size_t cch) +{ + wchar_t vendor[MAX_PATH]; + wchar_t product[MAX_PATH]; + if (base == NULL || out == NULL) + return 1; + if (sd_setup_path_join(vendor, MAX_PATH, base, L"LibreStatic") != 0) + return 1; + if (sd_setup_path_join(product, MAX_PATH, vendor, L"ShellDrive") != 0) + return 1; + return sd_setup_path_join(out, cch, product, L"setup"); +} + +/* 1 when leaf is 32 lower-case or upper-case hex digits (a stage folder name from sd_setup_create_stage_dir). */ +static int stage_leaf_ok(const wchar_t *leaf) +{ + size_t i; + if (leaf == NULL || wcslen(leaf) != SD_ELEV_HEX_LEN) + return 0; + for (i = 0; i < SD_ELEV_HEX_LEN; i++) { + wchar_t c = leaf[i]; + if (c >= L'A' && c <= L'F') + c = (wchar_t)(c + (L'a' - L'A')); + if (!is_hex(c)) + return 0; + } + return 1; +} + +/* 1 when image is \<32 hex>\shelldrive-setup.exe. Compares text only; nothing is opened. */ +static int image_in_stage(const wchar_t *root, const wchar_t *image) +{ + wchar_t dir[MAX_PATH]; + wchar_t *slash; + + if (wcslen(image) >= MAX_PATH) + return 0; + StringCchCopyW(dir, SD_SETUP_COUNTOF(dir), image); + slash = wcsrchr(dir, L'\\'); + if (slash == NULL || _wcsicmp(slash + 1, SD_ELEV_STAGE_LEAF) != 0) + return 0; + *slash = L'\0'; + slash = wcsrchr(dir, L'\\'); + if (slash == NULL || !stage_leaf_ok(slash + 1)) + return 0; + *slash = L'\0'; + return _wcsicmp(dir, root) == 0; +} + +/* The folder of a path: the path without its last component. Returns 0 or 1. */ +static int parent_of(const wchar_t *path, wchar_t *out, size_t cch) +{ + wchar_t *slash; + if (wcslen(path) >= cch) + return 1; + StringCchCopyW(out, cch, path); + slash = wcsrchr(out, L'\\'); + if (slash == NULL) + return 1; + *slash = L'\0'; + return 0; +} + +/* ------------------------------------------------------------------ the real seam */ + +static int real_is_elevated(void *ctx) +{ + sd_setup_host h; + (void)ctx; + sd_setup_detect_host(&h); + return h.is_admin; +} + +static DWORD real_secure(void *ctx, const wchar_t *base) +{ + sd_setup_trust_result tr; + (void)ctx; + return sd_setup_secure_programdata(base, &tr); +} + +static DWORD real_create_stage(void *ctx, const wchar_t *setup_dir, const wchar_t *extra_ace, wchar_t *out, size_t cch) +{ + (void)ctx; + return sd_setup_create_stage_dir(setup_dir, extra_ace, out, cch); +} + +static DWORD real_copy_image(void *ctx, const wchar_t *src, const wchar_t *dst) +{ + HANDLE in; + HANDLE out; + unsigned char *buf; + DWORD got = 0; + DWORD put = 0; + DWORD err = 0; + + (void)ctx; + /* Read sharing only: nobody can write or delete the source while it is copied (04 §6.4, stage 1 step d). */ + in = CreateFileW(src, GENERIC_READ, FILE_SHARE_READ, NULL, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL); + if (in == INVALID_HANDLE_VALUE) + return GetLastError(); + out = CreateFileW(dst, GENERIC_WRITE, 0, NULL, CREATE_NEW, FILE_ATTRIBUTE_NORMAL, NULL); + if (out == INVALID_HANDLE_VALUE) { + err = GetLastError(); + CloseHandle(in); + return err; + } + buf = (unsigned char *)HeapAlloc(GetProcessHeap(), 0, 65536); + if (buf == NULL) { + CloseHandle(out); + CloseHandle(in); + return ERROR_NOT_ENOUGH_MEMORY; + } + for (;;) { + if (!ReadFile(in, buf, 65536, &got, NULL)) { + err = GetLastError(); + break; + } + if (got == 0) + break; + if (!WriteFile(out, buf, got, &put, NULL) || put != got) { + err = ERROR_WRITE_FAULT; + break; + } + } + HeapFree(GetProcessHeap(), 0, buf); + CloseHandle(out); + CloseHandle(in); + return err; +} + +static DWORD real_run(void *ctx, const wchar_t *exe, const wchar_t *cmdline, const wchar_t *dir, int runas, + DWORD *exit_code) +{ + wchar_t cmd[SD_ELEV_CMD_CCH]; + STARTUPINFOW si; + PROCESS_INFORMATION pi; + SHELLEXECUTEINFOW sei; + HANDLE proc = NULL; + DWORD err = 0; + + (void)ctx; + if (exit_code == NULL || exe == NULL || cmdline == NULL) + return ERROR_INVALID_PARAMETER; + if (FAILED(StringCchCopyW(cmd, SD_ELEV_CMD_CCH, cmdline))) + return ERROR_INSUFFICIENT_BUFFER; + + if (runas) { + /* Vista and later: the UAC prompt. XP: the Run As dialog for a limited user (04 §6.4). A refusal is ERROR_CANCELLED. */ + memset(&sei, 0, sizeof(sei)); + sei.cbSize = sizeof(sei); + sei.fMask = SEE_MASK_NOCLOSEPROCESS; + sei.lpVerb = L"runas"; + sei.lpFile = exe; + sei.lpParameters = cmd; + sei.lpDirectory = dir; + sei.nShow = SW_HIDE; + if (!ShellExecuteExW(&sei)) + return GetLastError(); + proc = sei.hProcess; + } else { + memset(&si, 0, sizeof(si)); + si.cb = sizeof(si); + memset(&pi, 0, sizeof(pi)); + if (!CreateProcessW(exe, cmd, NULL, NULL, FALSE, 0, NULL, dir, &si, &pi)) + return GetLastError(); + CloseHandle(pi.hThread); + proc = pi.hProcess; + } + if (proc == NULL) + return ERROR_INTERNAL_ERROR; + WaitForSingleObject(proc, INFINITE); + if (!GetExitCodeProcess(proc, exit_code)) + err = GetLastError(); + CloseHandle(proc); + return err; +} + +static int real_folder_trusted(void *ctx, const wchar_t *dir) +{ + svc_repair_ops ops; + DWORD attrs; + PSID owner = NULL; + PSECURITY_DESCRIPTOR sd = NULL; + int ok = 0; + + (void)ctx; + attrs = GetFileAttributesW(dir); + if (attrs == INVALID_FILE_ATTRIBUTES || (attrs & FILE_ATTRIBUTE_DIRECTORY) == 0 || + (attrs & FILE_ATTRIBUTE_REPARSE_POINT) != 0) + return 0; + if (GetNamedSecurityInfoW((LPWSTR)dir, SE_FILE_OBJECT, OWNER_SECURITY_INFORMATION, &owner, NULL, NULL, NULL, + &sd) != ERROR_SUCCESS) + return 0; + svc_repair_real_ops(&ops); + ok = ops.owner_trusted != NULL && ops.owner_trusted(ops.ctx, owner) != 0; + LocalFree(sd); + return ok; +} + +static int real_find_machine_product(void *ctx, wchar_t *code, size_t cch) +{ + (void)ctx; + return sd_setup_machine_product(code, cch); +} + +static int real_machine_dir(void *ctx, wchar_t *out, size_t cch) +{ + (void)ctx; + return sd_setup_machine_install_dir(out, cch) == 0; +} + +static int real_launch(void *ctx, const wchar_t *exe, const wchar_t *cmdline, const wchar_t *dir) +{ + wchar_t cmd[SD_ELEV_CMD_CCH]; + STARTUPINFOW si; + PROCESS_INFORMATION pi; + + (void)ctx; + if (exe == NULL || cmdline == NULL || FAILED(StringCchCopyW(cmd, SD_ELEV_CMD_CCH, cmdline))) + return 0; + memset(&si, 0, sizeof(si)); + si.cb = sizeof(si); + memset(&pi, 0, sizeof(pi)); + /* No runas and no wait: the UI runs with this process's token, the logged-on user (04 §6.4). */ + if (!CreateProcessW(exe, cmd, NULL, NULL, FALSE, 0, NULL, dir, &si, &pi)) + return 0; + CloseHandle(pi.hThread); + CloseHandle(pi.hProcess); + return 1; +} + +void sd_setup_elev_real_ops(sd_setup_elev_ops *out) +{ + out->ctx = NULL; + out->is_elevated = real_is_elevated; + out->secure_programdata = real_secure; + out->create_stage = real_create_stage; + out->copy_image = real_copy_image; + out->run = real_run; + out->folder_trusted = real_folder_trusted; + out->find_machine_product = real_find_machine_product; + out->machine_dir = real_machine_dir; + out->launch = real_launch; +} + +/* ------------------------------------------------------------------ pipe */ + +DWORD sd_setup_elev_pipe_create(const wchar_t *name, HANDLE *out) +{ + wchar_t path[128]; + wchar_t sid[128]; + wchar_t sddl[256]; + PSECURITY_DESCRIPTOR sd = NULL; + SECURITY_ATTRIBUTES sa; + HANDLE h; + DWORD err; + + if (out == NULL || !sd_setup_elev_pipe_name_ok(name)) + return ERROR_INVALID_PARAMETER; + *out = INVALID_HANDLE_VALUE; + if (FAILED(StringCchPrintfW(path, SD_SETUP_COUNTOF(path), L"\\\\.\\pipe\\%s", name))) + return ERROR_BUFFER_OVERFLOW; + if (sd_setup_current_sid(sid, SD_SETUP_COUNTOF(sid)) != 0) + return ERROR_NO_TOKEN; + StringCchPrintfW(sddl, SD_SETUP_COUNTOF(sddl), k_pipe_sddl_fmt, sid); + if (!ConvertStringSecurityDescriptorToSecurityDescriptorW(sddl, SDDL_REVISION_1, &sd, NULL)) + return GetLastError(); + memset(&sa, 0, sizeof(sa)); + sa.nLength = sizeof(sa); + sa.lpSecurityDescriptor = sd; + sa.bInheritHandle = FALSE; + /* FIRST_PIPE_INSTANCE: a name created earlier by someone else makes this fail. */ + h = CreateNamedPipeW(path, PIPE_ACCESS_INBOUND | FILE_FLAG_FIRST_PIPE_INSTANCE, + PIPE_TYPE_BYTE | PIPE_READMODE_BYTE | PIPE_NOWAIT, 1, 0, 4096, 0, &sa); + err = h == INVALID_HANDLE_VALUE ? GetLastError() : 0; + LocalFree(sd); + if (h != INVALID_HANDLE_VALUE) + *out = h; + return err; +} + +DWORD sd_setup_elev_pipe_open(const wchar_t *name, HANDLE *out) +{ + wchar_t path[128]; + HANDLE h; + + if (out == NULL || !sd_setup_elev_pipe_name_ok(name)) + return ERROR_INVALID_PARAMETER; + *out = INVALID_HANDLE_VALUE; + if (FAILED(StringCchPrintfW(path, SD_SETUP_COUNTOF(path), L"\\\\.\\pipe\\%s", name))) + return ERROR_BUFFER_OVERFLOW; + WaitNamedPipeW(path, 5000); + h = CreateFileW(path, GENERIC_WRITE, 0, NULL, OPEN_EXISTING, 0, NULL); + if (h == INVALID_HANDLE_VALUE) + return GetLastError(); + *out = h; + return 0; +} + +DWORD sd_setup_elev_pipe_write(HANDLE client, const wchar_t *text) +{ + wchar_t line[256]; + DWORD put = 0; + DWORD bytes; + + if (client == INVALID_HANDLE_VALUE || client == NULL || text == NULL) + return ERROR_INVALID_PARAMETER; + if (FAILED(StringCchPrintfW(line, SD_SETUP_COUNTOF(line), L"%s\n", text))) + return ERROR_BUFFER_OVERFLOW; + bytes = (DWORD)(wcslen(line) * sizeof(wchar_t)); + if (!WriteFile(client, line, bytes, &put, NULL) || put != bytes) + return ERROR_WRITE_FAULT; + return 0; +} + +DWORD sd_setup_elev_pipe_collect(HANDLE server, wchar_t *out, size_t cch) +{ + wchar_t chunk[256]; + DWORD got = 0; + size_t used = 0; + + if (out == NULL || cch == 0) + return ERROR_INVALID_PARAMETER; + out[0] = L'\0'; + if (server == INVALID_HANDLE_VALUE || server == NULL) + return ERROR_INVALID_HANDLE; + /* The server end is nonblocking (CreateNamedPipe): a missing client gives ERROR_PIPE_LISTENING, never a wait. */ + if (!ConnectNamedPipe(server, NULL)) { + DWORD e = GetLastError(); + if (e != ERROR_PIPE_CONNECTED && e != ERROR_PIPE_LISTENING && e != ERROR_NO_DATA) + return e; + } + for (;;) { + size_t n; + if (!ReadFile(server, chunk, sizeof(chunk), &got, NULL)) { + DWORD e = GetLastError(); + if (e == ERROR_NO_DATA || e == ERROR_BROKEN_PIPE || e == ERROR_PIPE_LISTENING) + break; + return e; + } + if (got == 0) + break; + if (got % sizeof(wchar_t) != 0) + return ERROR_INVALID_DATA; + n = got / sizeof(wchar_t); + if (used + n + 1 > cch) + return ERROR_BUFFER_OVERFLOW; + memcpy(out + used, chunk, got); + used += n; + } + out[used] = L'\0'; + return 0; +} + +/* The number after the last "R " that starts a line. */ +int sd_setup_elev_result_line(const wchar_t *text, DWORD *exit_code) +{ + const wchar_t *p; + const wchar_t *found = NULL; + DWORD code = 0; + + if (text == NULL || exit_code == NULL) + return 1; + for (p = text; *p != L'\0'; p++) { + if ((p == text || p[-1] == L'\n') && p[0] == L'R' && p[1] == L' ') + found = p; + } + if (found == NULL || found[2] < L'0' || found[2] > L'9') + return 1; + for (p = found + 2; *p >= L'0' && *p <= L'9'; p++) + code = code * 10 + (DWORD)(*p - L'0'); + *exit_code = code; + return 0; +} + +/* The "M " line, if any. Returns 1 when one was found. */ +static int message_line(const wchar_t *text, sd_msg_id *msg, long *code) +{ + const wchar_t *p; + unsigned long id = 0; + long c = 0; + int neg = 0; + + for (p = text; *p != L'\0'; p++) { + if ((p == text || p[-1] == L'\n') && p[0] == L'M' && p[1] == L' ') + break; + } + if (*p == L'\0') + return 0; + p += 2; + if (*p < L'0' || *p > L'9') + return 0; + for (; *p >= L'0' && *p <= L'9'; p++) + id = id * 10 + (unsigned long)(*p - L'0'); + if (*p != L' ') + return 0; + p++; + if (*p == L'-') { + neg = 1; + p++; + } + if (*p < L'0' || *p > L'9') + return 0; + for (; *p >= L'0' && *p <= L'9'; p++) + c = c * 10 + (long)(*p - L'0'); + *msg = (sd_msg_id)id; + *code = neg ? -c : c; + return 1; +} + +/* ------------------------------------------------------------------ forwarded switches */ + +int sd_setup_elev_forward(const sd_setup_args *args, wchar_t *out, size_t cch) +{ + wchar_t tmp[128]; + const wchar_t *dokany; + + if (args == NULL || out == NULL || cch == 0) + return 1; + out[0] = L'\0'; + if (args->quiet) + StringCchCatW(out, cch, L"/quiet "); + else if (args->passive) + StringCchCatW(out, cch, L"/passive "); + if (args->mode == SD_SETUP_MODE_UNINSTALL) + StringCchCatW(out, cch, L"/uninstall "); + if (args->norestart) + StringCchCatW(out, cch, L"/norestart "); + if (args->force) + StringCchCatW(out, cch, L"/force "); + if (args->update) + StringCchCatW(out, cch, L"/update "); + if (args->dokany_set) { + dokany = args->dokany == SD_SETUP_DOKANY_SKIP ? L"skip" : args->dokany == SD_SETUP_DOKANY_INSTALL ? L"install" : L"auto"; + StringCchPrintfW(tmp, SD_SETUP_COUNTOF(tmp), L"/dokany=%s ", dokany); + StringCchCatW(out, cch, tmp); + } + if (args->lang[0] != L'\0') { + StringCchPrintfW(tmp, SD_SETUP_COUNTOF(tmp), L"/lang=%s ", args->lang); + StringCchCatW(out, cch, tmp); + } + if (args->flavour_forced) { + /* The same flavour in every stage: the stages would otherwise pick their own (flavour_forced is checked by the host). */ + StringCchPrintfW(tmp, SD_SETUP_COUNTOF(tmp), L"/flavour=%s ", sd_setup_flavour_name(args->flavour)); + StringCchCatW(out, cch, tmp); + } + StringCchCatW(out, cch, args->props); + return 0; +} + +/* ------------------------------------------------------------------ parent */ + +static int fail_result(sd_setup_result *r, int exit_code, sd_msg_id msg, long code) +{ + memset(r, 0, sizeof(*r)); + r->exit_code = exit_code; + r->msg = msg; + r->code = code; + return exit_code; +} + +/* One stage start, run on a worker thread so that the parent can relay the progress lines while it waits. */ +typedef struct elev_job { + const sd_setup_elev_ops *ops; + const wchar_t *base; + const wchar_t *self; + sd_setup_args args; + DWORD rc; + DWORD child; +} elev_job; + +static DWORD WINAPI elev_job_main(LPVOID param) +{ + elev_job *j = (elev_job *)param; + wchar_t fwd[SD_ELEV_FWD_CCH]; + wchar_t cmd[SD_ELEV_CMD_CCH]; + + if (j->ops->is_elevated(j->ops->ctx)) { + /* Already an administrator (XP, UAC off, or an elevated launch): no runas, but the same stage 1 (04 §6.4). */ + j->args.elevate_stage = 1; + j->rc = sd_setup_elev_stage1(j->ops, j->base, j->self, &j->args, &j->child); + } else if (sd_setup_elev_forward(&j->args, fwd, SD_SETUP_COUNTOF(fwd)) != 0) { + j->rc = ERROR_BUFFER_OVERFLOW; + } else { + /* No path goes to the elevated stages (TM-R33, TM-R34): the /log= copy is written by the parent from the pipe. */ + StringCchPrintfW(cmd, SD_SETUP_COUNTOF(cmd), L"\"%s\" /elevated-stage1 /pipe=%s %s", j->self, j->args.pipe_name, + fwd); + j->rc = j->ops->run(j->ops->ctx, j->self, cmd, NULL, 1, &j->child); + } + return 0; +} + +/* The result channel, read by the parent (04 §6.4). Lines are UTF-16: "P ", "M " and "R " are control text + (kept in text). The log block is "G" (opens it), then "L " lines of 1 to 64 bytes of msi.log, then "T" when the log + was cut at SD_ELEV_LOG_CAP, and the block ends with the "R" line. The parent treats the block as bytes only: it never + reads a path or a command from it. Anything malformed or oversized sets bad, and nothing is written. */ +#define SD_ELEV_LOG_CAP ((size_t)4 * 1024 * 1024) +#define SD_ELEV_LINE_CCH 256 +#define SD_ELEV_TEXT_CCH 2048 + +typedef struct elev_rx { + wchar_t line[SD_ELEV_LINE_CCH]; + size_t nline; + int discard; /* the current line is too long: skip to its end */ + int have_odd; /* a read ended on half a UTF-16 unit */ + unsigned char odd; + wchar_t text[SD_ELEV_TEXT_CCH]; + size_t ntext; + int block; /* 1 after "G" */ + int truncated; /* 1 after "T" */ + int bad; /* malformed or oversized: nothing is written */ + unsigned char *log; + size_t nlog; +} elev_rx; + +static int hex_nibble(wchar_t c) +{ + if (c >= L'0' && c <= L'9') + return c - L'0'; + if (c >= L'a' && c <= L'f') + return c - L'a' + 10; + if (c >= L'A' && c <= L'F') + return c - L'A' + 10; + return -1; +} + +static void rx_hex_line(elev_rx *rx, const wchar_t *hex, size_t m) +{ + size_t i; + size_t nb; + + if (!rx->block || m == 0 || (m % 2) != 0 || m > 2 * 64 || rx->log == NULL) { + rx->bad = 1; + return; + } + nb = m / 2; + if (rx->nlog + nb > SD_ELEV_LOG_CAP) { + rx->bad = 1; + return; + } + for (i = 0; i < nb; i++) { + int hi = hex_nibble(hex[2 * i]); + int lo = hex_nibble(hex[2 * i + 1]); + if (hi < 0 || lo < 0) { + rx->bad = 1; + return; + } + rx->log[rx->nlog++] = (unsigned char)((hi << 4) | lo); + } +} + +static void rx_line(elev_rx *rx) +{ + const wchar_t *l = rx->line; + size_t n = rx->nline; + + /* A bad log block only drops the log (rx->bad); control lines still count, so the stage result is never lost. */ + if (n == 1 && l[0] == L'G') { + if (rx->block) { + rx->bad = 1; + return; + } + rx->block = 1; + rx->log = (unsigned char *)HeapAlloc(GetProcessHeap(), 0, SD_ELEV_LOG_CAP); + if (rx->log == NULL) + rx->bad = 1; + return; + } + if (n == 1 && l[0] == L'T') { + if (!rx->block) + rx->bad = 1; + else + rx->truncated = 1; + return; + } + if (n >= 2 && l[0] == L'L' && l[1] == L' ') { + rx_hex_line(rx, l + 2, n - 2); + return; + } + /* Control text: appended with its line break, so that the line-start tests of last_progress and the result parsers hold. */ + if (rx->ntext + n + 2 > SD_ELEV_TEXT_CCH) { + rx->bad = 1; + return; + } + memcpy(rx->text + rx->ntext, l, n * sizeof(wchar_t)); + rx->ntext += n; + rx->text[rx->ntext++] = L'\n'; + rx->text[rx->ntext] = L'\0'; +} + +static void rx_char(elev_rx *rx, wchar_t c) +{ + if (c == L'\n') { + if (!rx->discard) + rx_line(rx); + rx->nline = 0; + rx->discard = 0; + return; + } + if (rx->discard) + return; + if (rx->nline + 1 >= SD_ELEV_LINE_CCH) { + rx->bad = 1; + rx->discard = 1; + return; + } + rx->line[rx->nline++] = c; +} + +/* Feeds n bytes of the pipe to the line reader (UTF-16LE). */ +static void rx_bytes(elev_rx *rx, const unsigned char *p, DWORD n) +{ + DWORD i = 0; + + if (rx->have_odd && n > 0) { + rx_char(rx, (wchar_t)(rx->odd | ((unsigned)p[0] << 8))); + rx->have_odd = 0; + i = 1; + } + for (; i + 1 < n; i += 2) + rx_char(rx, (wchar_t)(p[i] | ((unsigned)p[i + 1] << 8))); + if (i < n) { + rx->odd = p[i]; + rx->have_odd = 1; + } +} + +/* Reads everything the stage has written so far (the server end is nonblocking). Returns the number of bytes read. */ +static DWORD rx_read(HANDLE server, elev_rx *rx) +{ + unsigned char buf[1024]; + DWORD got = 0; + DWORD total = 0; + DWORD e; + + if (!ConnectNamedPipe(server, NULL)) { + e = GetLastError(); + if (e != ERROR_PIPE_CONNECTED && e != ERROR_PIPE_LISTENING && e != ERROR_NO_DATA) + return 0; + } + for (;;) { + got = 0; + if (!ReadFile(server, buf, sizeof(buf), &got, NULL) || got == 0) + break; + rx_bytes(rx, buf, got); + total += got; + } + return total; +} + +/* The last "P " line of the control text: 1 with *value set when there is one. */ +static int last_progress(const wchar_t *text, unsigned *value) +{ + const wchar_t *p; + int found = 0; + unsigned v = 0; + + for (p = text; *p != L'\0'; p++) { + if ((p == text || p[-1] == L'\n') && p[0] == L'P' && p[1] == L' ' && p[2] >= L'0' && p[2] <= L'9') { + const wchar_t *q; + v = 0; + for (q = p + 2; *q >= L'0' && *q <= L'9'; q++) + v = v * 10 + (unsigned)(*q - L'0'); + if (v > 100) + v = 100; + found = 1; + } + } + *value = v; + return found; +} + +/* Drains the pipe and reports a new percentage. Returns 1 when any byte was read. */ +static int relay_read(HANDLE server, elev_rx *rx, sd_setup_elev_progress_fn progress, void *ctx, unsigned *last) +{ + unsigned value = 0; + int got = rx_read(server, rx) != 0; + + if (last_progress(rx->text, &value) && value != *last) { + *last = value; + if (progress != NULL) + progress(ctx, value); + } + return got; +} + +/* 1 when path may take the log copy: an absolute drive path of a .log file, no parent-folder component, in a folder that + exists and is not a reparse point. An existing target must be a plain file (it is replaced). */ +static int log_target_ok(const wchar_t *path) +{ + wchar_t dir[MAX_PATH]; + size_t n = wcslen(path); + size_t i; + DWORD a; + + if (n < 8 || n >= MAX_PATH || path[1] != L':' || path[2] != L'\\' || wcschr(path, L'"') != NULL) + return 0; + for (i = 0; i + 1 < n; i++) { + if (path[i] == L'.' && path[i + 1] == L'.') + return 0; + } + if (_wcsicmp(path + n - 4, L".log") != 0 || parent_of(path, dir, MAX_PATH) != 0) + return 0; + a = GetFileAttributesW(dir); + if (a == INVALID_FILE_ATTRIBUTES || (a & FILE_ATTRIBUTE_DIRECTORY) == 0 || (a & FILE_ATTRIBUTE_REPARSE_POINT) != 0) + return 0; + a = GetFileAttributesW(path); + if (a != INVALID_FILE_ATTRIBUTES && ((a & FILE_ATTRIBUTE_DIRECTORY) != 0 || (a & FILE_ATTRIBUTE_REPARSE_POINT) != 0)) + return 0; + return 1; +} + +/* Writes the bytes to path with the parent's own token (CREATE_ALWAYS). 0 or 1. */ +static int write_log_file(const wchar_t *path, const unsigned char *data, size_t n) +{ + HANDLE h = CreateFileW(path, GENERIC_WRITE, 0, NULL, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL); + DWORD put = 0; + int ok = 1; + + if (h == INVALID_HANDLE_VALUE) + return 1; + if (n > 0 && (!WriteFile(h, data, (DWORD)n, &put, NULL) || put != (DWORD)n)) + ok = 0; + CloseHandle(h); + return ok ? 0 : 1; +} + +/* After the stage has finished: writes the relayed log block to log_path, or logs why it did not. Frees the block. */ +static void elev_finish_log(elev_rx *rx, const wchar_t *log_path) +{ + if (rx->block && !rx->bad && log_path != NULL && log_path[0] != L'\0') { + if (!log_target_ok(log_path)) { + sd_setup_log("elevate.log_refused", L"the log path is not a usable .log file; nothing was written"); + } else if (write_log_file(log_path, rx->log, rx->nlog) == 0) { + sd_setup_log(rx->truncated ? "elevate.log_truncated" : "elevate.log_written", + rx->truncated ? L"the stage log was cut at the size cap" : L"the stage log was written to the log path"); + } else { + sd_setup_log("elevate.log_failed", L"the stage log could not be written"); + } + } else if (rx->block && rx->bad) { + sd_setup_log("elevate.log_malformed", L"the stage log block was malformed or too large; nothing was written"); + } + if (rx->log != NULL) + HeapFree(GetProcessHeap(), 0, rx->log); + rx->log = NULL; +} + +int sd_setup_elev_parent_ex(const sd_setup_elev_ops *ops, const wchar_t *base, const wchar_t *self, + const sd_setup_args *args, sd_setup_result *r, sd_setup_elev_progress_fn progress, + void *ctx) +{ + elev_job job; + elev_rx rx; + HANDLE server = INVALID_HANDLE_VALUE; + HANDLE thread = NULL; + sd_msg_id msg = 0; + long mcode = 0; + unsigned last = (unsigned)-1; + int drained = 0; + DWORD rc; + + if (r == NULL) + return SD_SETUP_EXIT_FATAL; + memset(r, 0, sizeof(*r)); + if (ops == NULL || base == NULL || self == NULL || args == NULL) + return fail_result(r, SD_SETUP_EXIT_FATAL, SD_MSG_INSTALLER_ERROR_GENERIC, 0); + + memset(&job, 0, sizeof(job)); + memset(&rx, 0, sizeof(rx)); + job.ops = ops; + job.base = base; + job.self = self; + job.args = *args; + if (sd_setup_elev_pipe_new_name(job.args.pipe_name, SD_SETUP_COUNTOF(job.args.pipe_name)) != 0) + return fail_result(r, SD_SETUP_EXIT_FATAL, SD_MSG_INSTALLER_ERROR_ELEVATION, 0); + rc = sd_setup_elev_pipe_create(job.args.pipe_name, &server); + if (rc != 0) + return fail_result(r, SD_SETUP_EXIT_FATAL, SD_MSG_INSTALLER_ERROR_ELEVATION, (long)rc); + sd_setup_log("elevate.parent", job.args.pipe_name); + + thread = CreateThread(NULL, 0, elev_job_main, &job, 0, NULL); + if (thread == NULL) { + CloseHandle(server); + return fail_result(r, SD_SETUP_EXIT_FATAL, SD_MSG_INSTALLER_ERROR_ELEVATION, (long)GetLastError()); + } + /* Drain the pipe while the stage runs: a busy pipe blocks the stage, so the wait is short while data keeps coming. + The tick lets the caller pump its window. */ + for (;;) { + DWORD w = WaitForSingleObject(thread, drained ? 0 : 20); + drained = relay_read(server, &rx, progress, ctx, &last); + if (progress != NULL) + progress(ctx, SD_SETUP_ELEV_TICK); + if (w != WAIT_TIMEOUT) + break; + } + CloseHandle(thread); + rc = job.rc; + + /* The stage has finished: read the rest, then write the log block (if any) with the parent's token. */ + relay_read(server, &rx, progress, ctx, &last); + CloseHandle(server); + elev_finish_log(&rx, args->log_path); + + if (rc == ERROR_CANCELLED) + return fail_result(r, SD_SETUP_EXIT_CANCEL, 0, 0); + if (rc != 0) { + sd_setup_log("elevate.failed", L"the elevated stage did not run"); + return fail_result(r, SD_SETUP_EXIT_FATAL, SD_MSG_INSTALLER_ERROR_ELEVATION, (long)rc); + } + + if (sd_setup_elev_result_line(rx.text, &job.child) != 0) + return fail_result(r, SD_SETUP_EXIT_FATAL, SD_MSG_INSTALLER_ERROR_ELEVATION, (long)job.child); + + if (message_line(rx.text, &msg, &mcode)) { + r->msg = msg; + r->code = mcode; + } + r->exit_code = (int)job.child; + if (job.child != SD_SETUP_EXIT_OK && job.child != SD_SETUP_EXIT_REBOOT && r->msg == 0) + r->msg = SD_MSG_INSTALLER_ERROR_ELEVATION; + sd_setup_log("elevate.result", L"the parent received the stage result"); + return r->exit_code; +} + +int sd_setup_elev_parent(const sd_setup_elev_ops *ops, const wchar_t *base, const wchar_t *self, + const sd_setup_args *args, sd_setup_result *r) +{ + return sd_setup_elev_parent_ex(ops, base, self, args, r, NULL, NULL); +} + +/* ------------------------------------------------------------------ stage 1 */ + +/* 1 when path is a plain file: it exists, is not a folder and is not a reparse point. */ +static int plain_file(const wchar_t *path) +{ + DWORD a = GetFileAttributesW(path); + return a != INVALID_FILE_ATTRIBUTES && (a & FILE_ATTRIBUTE_DIRECTORY) == 0 && (a & FILE_ATTRIBUTE_REPARSE_POINT) == 0; +} + +DWORD sd_setup_elev_stage1(const sd_setup_elev_ops *ops, const wchar_t *base, const wchar_t *self, + const sd_setup_args *args, DWORD *child_exit) +{ + wchar_t root[MAX_PATH]; + wchar_t stage[MAX_PATH]; + wchar_t image[MAX_PATH]; + wchar_t fwd[SD_ELEV_FWD_CCH]; + wchar_t cmd[SD_ELEV_CMD_CCH]; + DWORD rc; + + if (ops == NULL || base == NULL || self == NULL || args == NULL || child_exit == NULL) + return ERROR_INVALID_PARAMETER; + *child_exit = (DWORD)SD_SETUP_EXIT_FATAL; + if (!ops->is_elevated(ops->ctx)) + return ERROR_ACCESS_DENIED; + if (!sd_setup_elev_pipe_name_ok(args->pipe_name)) + return ERROR_INVALID_PARAMETER; + + /* The vendor folders first (04 §6.4, TM-R34), then the stage root. */ + rc = ops->secure_programdata(ops->ctx, base); + if (rc != 0) + return rc; + if (sd_setup_elev_root(base, root, SD_SETUP_COUNTOF(root)) != 0) + return ERROR_BUFFER_OVERFLOW; + if (sd_setup_mkdirs(root) != 0) + return ERROR_PATH_NOT_FOUND; + + /* Step c: a new folder with its protected DACL in one call. */ + rc = ops->create_stage(ops->ctx, root, NULL, stage, SD_SETUP_COUNTOF(stage)); + if (rc != 0) + return rc; + if (sd_setup_path_join(image, MAX_PATH, stage, SD_ELEV_STAGE_LEAF) != 0) + return ERROR_BUFFER_OVERFLOW; + + /* Step d: its own image. */ + rc = ops->copy_image(ops->ctx, self, image); + if (rc != 0) + return rc; + + /* Step e: stage 2 from the copy, with the same elevated token, and wait for it. */ + if (sd_setup_elev_forward(args, fwd, SD_SETUP_COUNTOF(fwd)) != 0) + return ERROR_BUFFER_OVERFLOW; + StringCchPrintfW(cmd, SD_SETUP_COUNTOF(cmd), L"\"%s\" /elevated-child /pipe=%s %s", image, args->pipe_name, fwd); + rc = ops->run(ops->ctx, image, cmd, stage, 0, child_exit); + if (rc != 0) + return rc; + sd_setup_log("elevate.stage1", L"stage 2 finished"); + /* A successful stage removes its folder; a failed one keeps the MSI log for diagnosis (admin-only folder). */ + if (*child_exit == 0) + sd_setup_remove_tree(stage, NULL); + return 0; +} + +/* ------------------------------------------------------------------ stage 2 */ + +/* Reports the message (when msg is not 0) and the exit code, then closes the channel. */ +static DWORD stage2_end(HANDLE pipe, DWORD exit_code, sd_msg_id msg, long code) +{ + wchar_t line[128]; + if (msg != 0) { + StringCchPrintfW(line, SD_SETUP_COUNTOF(line), L"M %lu %ld", (unsigned long)msg, code); + sd_setup_elev_pipe_write(pipe, line); + } + StringCchPrintfW(line, SD_SETUP_COUNTOF(line), L"R %lu", (unsigned long)exit_code); + sd_setup_elev_pipe_write(pipe, line); + CloseHandle(pipe); + sd_setup_log("elevate.stage2", line); + return exit_code; +} + +static void progress(HANDLE pipe, unsigned percent) +{ + wchar_t line[32]; + StringCchPrintfW(line, SD_SETUP_COUNTOF(line), L"P %u", percent); + sd_setup_elev_pipe_write(pipe, line); +} + +/* Sends msi.log of the stage folder over the pipe: "G", then "L " lines of up to 64 bytes, then "T" when the file is + larger than SD_ELEV_LOG_CAP (only the first SD_ELEV_LOG_CAP bytes are sent). Nothing is sent when the file is not a + plain file. Stage 2 never writes outside its folder; the path to the user's /log= is not known here. */ +static void send_stage_log(HANDLE pipe, const wchar_t *dir) +{ + static const wchar_t digits[] = L"0123456789abcdef"; + wchar_t path[MAX_PATH]; + wchar_t line[SD_ELEV_LINE_CCH]; + unsigned char buf[64]; + HANDLE f; + DWORD got = 0; + size_t sent = 0; + size_t i; + int truncated = 0; + + sd_setup_elev_pipe_write(pipe, L"G"); + if (sd_setup_path_join(path, MAX_PATH, dir, SD_ELEV_MSI_LOG) != 0 || !plain_file(path)) + return; + f = CreateFileW(path, GENERIC_READ, FILE_SHARE_READ, NULL, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL); + if (f == INVALID_HANDLE_VALUE) + return; + for (;;) { + size_t want = sizeof(buf); + if (sent == SD_ELEV_LOG_CAP) { + /* At the cap: one more byte means the log is cut. */ + unsigned char probe; + DWORD pg = 0; + if (ReadFile(f, &probe, 1, &pg, NULL) && pg == 1) + truncated = 1; + break; + } + if (SD_ELEV_LOG_CAP - sent < want) + want = SD_ELEV_LOG_CAP - sent; + if (!ReadFile(f, buf, (DWORD)want, &got, NULL) || got == 0) + break; + line[0] = L'L'; + line[1] = L' '; + for (i = 0; i < got; i++) { + line[2 + 2 * i] = digits[buf[i] >> 4]; + line[3 + 2 * i] = digits[buf[i] & 15]; + } + line[2 + 2 * got] = L'\0'; + if (sd_setup_elev_pipe_write(pipe, line) != 0) + break; + sent += got; + } + CloseHandle(f); + if (truncated) + sd_setup_elev_pipe_write(pipe, L"T"); +} + +/* The checks every stage 2 starts with (04 §6.4): an administrator token, a pipe name of the right shape, and a stage folder + under the root that holds this image, with a trusted owner. Nothing from the command line is a path: the folder comes from + this image (TM-R33, TM-R34). Returns 0 with dir set, or 1 with the message and code to report. */ +static int stage2_guard(const sd_setup_elev_ops *ops, const wchar_t *base, const wchar_t *self, + const sd_setup_args *args, wchar_t *dir, size_t cch, sd_msg_id *msg, long *code) +{ + wchar_t root[MAX_PATH]; + + *msg = 0; + *code = 0; + if (!ops->is_elevated(ops->ctx)) { + *msg = SD_MSG_INSTALLER_ERROR_ELEVATION; + *code = ERROR_ACCESS_DENIED; + return 1; + } + if (!sd_setup_elev_pipe_name_ok(args->pipe_name) || sd_setup_elev_root(base, root, SD_SETUP_COUNTOF(root)) != 0) { + *msg = SD_MSG_INSTALLER_ERROR_ELEVATION; + *code = ERROR_INVALID_PARAMETER; + return 1; + } + if (!image_in_stage(root, self) || parent_of(self, dir, cch) != 0) { + *msg = SD_MSG_INSTALLER_ERROR_STAGE_UNTRUSTED; + return 1; + } + if (!ops->folder_trusted(ops->ctx, dir)) { + *msg = SD_MSG_INSTALLER_ERROR_STAGE_UNTRUSTED; + return 1; + } + return 0; +} + +DWORD sd_setup_elev_stage2(const sd_setup_elev_ops *ops, const wchar_t *base, const wchar_t *self, + const sd_setup_args *args, const sd_setup_payload *payload, + const unsigned char expected[32]) +{ + HANDLE pipe = INVALID_HANDLE_VALUE; + wchar_t dir[MAX_PATH]; + wchar_t pdir[MAX_PATH]; + wchar_t msi[MAX_PATH]; + wchar_t log[MAX_PATH]; + wchar_t exe[MAX_PATH]; + wchar_t cmd[SD_ELEV_CMD_CCH]; + unsigned char sha[32]; + sd_setup_result cab; + unsigned files = 0; + DWORD mcode = 0; + DWORD rc; + + if (ops == NULL || base == NULL || self == NULL || args == NULL || payload == NULL || expected == NULL) + return SD_SETUP_EXIT_FATAL; + /* Without the channel nothing can be reported, so nothing starts. */ + if (sd_setup_elev_pipe_open(args->pipe_name, &pipe) != 0) + return SD_SETUP_EXIT_FATAL; + + { + sd_msg_id gmsg = 0; + long gcode = 0; + if (stage2_guard(ops, base, self, args, dir, SD_SETUP_COUNTOF(dir), &gmsg, &gcode) != 0) + return stage2_end(pipe, SD_SETUP_EXIT_FATAL, gmsg, gcode); + } + + /* The payload table check again, on the bytes this image carries. */ + sd_setup_sha256(payload->data, payload->size, sha); + if (memcmp(sha, expected, 32) != 0) + return stage2_end(pipe, SD_SETUP_EXIT_FATAL, SD_MSG_INSTALLER_ERROR_PAYLOAD_DAMAGED, 0); + + progress(pipe, 10); + memset(&cab, 0, sizeof(cab)); + if (sd_setup_path_join(pdir, MAX_PATH, dir, SD_ELEV_PAYLOAD_DIR) != 0 || sd_setup_mkdirs(pdir) != 0) + return stage2_end(pipe, SD_SETUP_EXIT_FATAL, SD_MSG_INSTALLER_ERROR_WRITE, 0); + if (sd_setup_cab_extract(payload, pdir, NULL, &cab, &files) != 0) { + sd_setup_remove_tree(pdir, NULL); + return stage2_end(pipe, SD_SETUP_EXIT_FATAL, cab.msg != 0 ? cab.msg : SD_MSG_INSTALLER_ERROR_PAYLOAD_EXTRACT, 0); + } + progress(pipe, 40); + + /* The MSI must be a plain file in the payload folder: no link, no folder. */ + if (sd_setup_path_join(msi, MAX_PATH, pdir, SD_ELEV_MSI_LEAF) != 0 || !plain_file(msi)) { + sd_setup_remove_tree(pdir, NULL); + return stage2_end(pipe, SD_SETUP_EXIT_FATAL, SD_MSG_INSTALLER_ERROR_MSI_MISSING, 0); + } + if (sd_setup_path_join(log, MAX_PATH, dir, SD_ELEV_MSI_LOG) != 0 || + sd_setup_system_path(L"msiexec.exe", exe, MAX_PATH) != 0) { + sd_setup_remove_tree(pdir, NULL); + return stage2_end(pipe, SD_SETUP_EXIT_FATAL, SD_MSG_INSTALLER_ERROR_MSI_FAILED, 0); + } + StringCchPrintfW(cmd, SD_SETUP_COUNTOF(cmd), L"\"%s\" /i \"%s\" %s%s /L*v \"%s\" %s", exe, msi, + args->quiet ? L"/qn" : L"/qb", args->norestart ? L" /norestart" : L"", log, args->props); + + progress(pipe, 60); + rc = ops->run(ops->ctx, exe, cmd, dir, 0, &mcode); + /* The msiexec log goes back over the pipe, before the result line (the parent writes the /log= copy). */ + send_stage_log(pipe, dir); + sd_setup_remove_tree(pdir, NULL); + if (rc != 0) + return stage2_end(pipe, SD_SETUP_EXIT_FATAL, SD_MSG_INSTALLER_ERROR_MSI_FAILED, (long)rc); + if (mcode != SD_SETUP_EXIT_OK && mcode != SD_SETUP_EXIT_REBOOT) + return stage2_end(pipe, mcode, SD_MSG_INSTALLER_ERROR_MSI_FAILED, (long)mcode); + + progress(pipe, 100); + return stage2_end(pipe, mcode, 0, 0); +} + +/* ------------------------------------------------------------------ per-machine uninstall and launch */ + +/* 1 when code has the shape of a braced product code (either letter case). */ +static int product_code_ok(const wchar_t *code) +{ + size_t i; + if (wcslen(code) != 38 || code[0] != L'{' || code[37] != L'}') + return 0; + for (i = 1; i < 37; i++) { + wchar_t c = code[i]; + if (i == 9 || i == 14 || i == 19 || i == 24) { + if (c != L'-') + return 0; + } else if (!is_hex(c) && !(c >= L'A' && c <= L'F')) { + return 0; + } + } + return 1; +} + +DWORD sd_setup_elev_stage2_uninstall(const sd_setup_elev_ops *ops, const wchar_t *base, const wchar_t *self, + const sd_setup_args *args) +{ + HANDLE pipe = INVALID_HANDLE_VALUE; + wchar_t dir[MAX_PATH]; + wchar_t log[MAX_PATH]; + wchar_t exe[MAX_PATH]; + wchar_t code[64]; + wchar_t cmd[SD_ELEV_CMD_CCH]; + sd_msg_id gmsg = 0; + long gcode = 0; + DWORD mcode = 0; + DWORD rc; + + if (ops == NULL || base == NULL || self == NULL || args == NULL) + return SD_SETUP_EXIT_FATAL; + if (sd_setup_elev_pipe_open(args->pipe_name, &pipe) != 0) + return SD_SETUP_EXIT_FATAL; + if (stage2_guard(ops, base, self, args, dir, SD_SETUP_COUNTOF(dir), &gmsg, &gcode) != 0) + return stage2_end(pipe, SD_SETUP_EXIT_FATAL, gmsg, gcode); + + /* The product code is the one the machine registry holds for the ShellDrive MSI. It is never taken from the command + line, and nothing runs when the registry has none or its value is not a product code. */ + code[0] = L'\0'; + if (!ops->find_machine_product(ops->ctx, code, SD_SETUP_COUNTOF(code)) || !product_code_ok(code)) + return stage2_end(pipe, SD_SETUP_EXIT_FATAL, SD_MSG_INSTALLER_ERROR_NOT_INSTALLED, 0); + + progress(pipe, 10); + if (sd_setup_path_join(log, MAX_PATH, dir, SD_ELEV_MSI_LOG) != 0 || + sd_setup_system_path(L"msiexec.exe", exe, MAX_PATH) != 0) + return stage2_end(pipe, SD_SETUP_EXIT_FATAL, SD_MSG_INSTALLER_ERROR_MSI_FAILED, 0); + StringCchPrintfW(cmd, SD_SETUP_COUNTOF(cmd), L"\"%s\" /x %s %s%s /L*v \"%s\" %s", exe, code, + args->quiet ? L"/qn" : L"/qb", args->norestart ? L" /norestart" : L"", log, args->props); + + progress(pipe, 60); + rc = ops->run(ops->ctx, exe, cmd, dir, 0, &mcode); + send_stage_log(pipe, dir); + if (rc != 0) + return stage2_end(pipe, SD_SETUP_EXIT_FATAL, SD_MSG_INSTALLER_ERROR_MSI_FAILED, (long)rc); + if (mcode != SD_SETUP_EXIT_OK && mcode != SD_SETUP_EXIT_REBOOT) + return stage2_end(pipe, mcode, SD_MSG_INSTALLER_ERROR_MSI_FAILED, (long)mcode); + + progress(pipe, 100); + return stage2_end(pipe, mcode, 0, 0); +} + +int sd_setup_elev_launch_installed(const sd_setup_elev_ops *ops) +{ + wchar_t dir[MAX_PATH]; + wchar_t exe[MAX_PATH]; + wchar_t cmd[MAX_PATH + 4]; + + if (ops == NULL || ops->is_elevated == NULL || ops->machine_dir == NULL || ops->launch == NULL) + return 1; + /* The UI must not run with an administrator token: a parent that holds one does not start it. */ + if (ops->is_elevated(ops->ctx)) { + sd_setup_log("launch.refused", L"the installer holds an administrator token; ShellDrive was not started"); + return 1; + } + if (!ops->machine_dir(ops->ctx, dir, MAX_PATH) || sd_setup_path_join(exe, MAX_PATH, dir, L"shelldrive-ui.exe") != 0) + return 1; + StringCchPrintfW(cmd, SD_SETUP_COUNTOF(cmd), L"\"%s\"", exe); + if (ops->launch(ops->ctx, exe, cmd, dir)) + return 0; + sd_setup_log("launch.failed", L"the installed ShellDrive could not be started"); + return 1; +} diff --git a/apps/shelldrive-setup/setup_elevate.h b/apps/shelldrive-setup/setup_elevate.h new file mode 100644 index 0000000..aefef37 --- /dev/null +++ b/apps/shelldrive-setup/setup_elevate.h @@ -0,0 +1,111 @@ +/* shelldrive-setup: two-stage elevation for per-machine installs (docs/design/04 §6.4, TM-R33, TM-R34, T-62, T-63). + * + * parent (asInvoker, keeps the UI) creates the progress pipe, starts stage 1 with "runas" (or in this process when + * the token is already elevated) on a worker thread, relays the "P" lines to the + * progress view while it waits, then reads the stage 2 result from the pipe. + * stage 1 (elevated, no window) secures %ProgramData%\LibreStatic\ShellDrive, creates a new protected stage folder + * setup\, copies its own image into it (FILE_SHARE_READ source), starts that copy + * as stage 2 with the same elevated token and waits. Then copies the stage log to the + * /log= path, when one was given (the parent passes it to stage 1 only). + * stage 2 (elevated, in the stage) re-derives everything from its own image: the folder must be a stage folder under the + * expected root with a trusted owner, the pipe name must have the expected shape. It + * extracts the payload into the stage folder, runs msiexec for the MSI and reports + * "P " and "R " lines through the pipe. + * + * Every process start, every elevation check and every msiexec call goes through the sd_setup_elev_ops seam. Tests supply a + * seam that records the command lines and never elevates or runs msiexec. */ +#ifndef SD_SETUP_ELEVATE_H +#define SD_SETUP_ELEVATE_H + +#include +#include + +#include "setup_core.h" + +typedef struct sd_setup_elev_ops { + void *ctx; + /* 1 when this process holds an administrator token (the real check: sd_setup_detect_host). */ + int (*is_elevated)(void *ctx); + /* Secures \LibreStatic and \LibreStatic\ShellDrive (04 §6.4, TM-R34). 0 or a Win32 error. */ + DWORD (*secure_programdata)(void *ctx, const wchar_t *base); + /* Creates a new folder under setup_dir with its protected DACL in one call; extra_ace is appended to the DACL (tests). */ + DWORD (*create_stage)(void *ctx, const wchar_t *setup_dir, const wchar_t *extra_ace, wchar_t *out, size_t cch); + /* Copies src to a new file dst. src is opened with FILE_SHARE_READ only (04 §6.4, stage 1 step d). */ + DWORD (*copy_image)(void *ctx, const wchar_t *src, const wchar_t *dst); + /* Starts exe with cmdline in dir and waits for it. runas uses the "runas" verb (UAC or the XP Run As dialog); otherwise + the process inherits this token. *exit_code gets the exit code. Also used for msiexec.exe. */ + DWORD (*run)(void *ctx, const wchar_t *exe, const wchar_t *cmdline, const wchar_t *dir, int runas, DWORD *exit_code); + /* 1 when dir is a real folder (not a reparse point) whose owner is trusted (SYSTEM or Administrators, 03 §3.2). */ + int (*folder_trusted)(void *ctx, const wchar_t *dir); + /* Stage 2 of an uninstall: the braced product code of the ShellDrive MSI in the machine registry (sd_setup_machine_product). + 1 when found. The code is the only thing msiexec /x receives; nothing from the command line. */ + int (*find_machine_product)(void *ctx, wchar_t *code, size_t cch); + /* The parent's install folder of the machine install (sd_setup_machine_install_dir). 1 when known. */ + int (*machine_dir)(void *ctx, wchar_t *out, size_t cch); + /* Starts exe with cmdline in dir and returns at once, with this process's token (never runas, never the elevated stage). + 1 when started. */ + int (*launch)(void *ctx, const wchar_t *exe, const wchar_t *cmdline, const wchar_t *dir); +} sd_setup_elev_ops; + +/* The shipping seam. */ +void sd_setup_elev_real_ops(sd_setup_elev_ops *out); + +/* The stage root: \LibreStatic\ShellDrive\setup (base is %ProgramData%, no trailing backslash). */ +int sd_setup_elev_root(const wchar_t *base, wchar_t *out, size_t cch); + +/* Pipe names: "ShellDrive-setup-" plus 32 lower-case hex digits. 1 when the name has that shape. */ +int sd_setup_elev_pipe_name_ok(const wchar_t *name); +int sd_setup_elev_pipe_new_name(wchar_t *out, size_t cch); + +/* Server end (parent): one instance, read side, DACL for the current user SID only. Returns 0 or a Win32 error. */ +DWORD sd_setup_elev_pipe_create(const wchar_t *name, HANDLE *out); +/* Client end (stage 2): opens the instance for writing. */ +DWORD sd_setup_elev_pipe_open(const wchar_t *name, HANDLE *out); +/* Writes one line (text plus a line break) to an open client end. */ +DWORD sd_setup_elev_pipe_write(HANDLE client, const wchar_t *text); +/* Server end, after the client has finished: reads every buffered line into out (NUL-terminated). Never waits for a client + that did not connect. Returns 0 when the pipe was read (possibly empty), or a Win32 error. */ +DWORD sd_setup_elev_pipe_collect(HANDLE server, wchar_t *out, size_t cch); +/* The exit code of the last "R " line of collected text. 1 when there is none. */ +int sd_setup_elev_result_line(const wchar_t *text, DWORD *exit_code); + +/* The forwarded switches of an elevated stage: quiet, passive, norestart, force, update, dokany, lang and the accepted + PROPERTY=value tokens. Never a path (04 §6.4: no path from the command line is trusted by the elevated stages). */ +int sd_setup_elev_forward(const sd_setup_args *args, wchar_t *out, size_t cch); + +/* Parent of a per-machine install (asInvoker). Creates the pipe, starts stage 1 with runas (or runs it here when this + process is already elevated), waits, and reads the result. r gets the exit code and, for a failure, its message. A UAC + refusal gives SD_SETUP_EXIT_CANCEL with no message. */ +int sd_setup_elev_parent(const sd_setup_elev_ops *ops, const wchar_t *base, const wchar_t *self, + const sd_setup_args *args, sd_setup_result *r); + +/* The progress of the stage while the parent waits: progress(ctx, n) for each new "P " percentage (0..100), and + progress(ctx, SD_SETUP_ELEV_TICK) about every 100 ms, so that the caller can pump its window. progress may be NULL. */ +#define SD_SETUP_ELEV_TICK 0xFFFFFFFFu +typedef void (*sd_setup_elev_progress_fn)(void *ctx, unsigned value); +int sd_setup_elev_parent_ex(const sd_setup_elev_ops *ops, const wchar_t *base, const wchar_t *self, + const sd_setup_args *args, sd_setup_result *r, sd_setup_elev_progress_fn progress, + void *ctx); + +/* Stage 1 (elevated). Returns 0 when stage 2 reported success, else a Win32 error or the stage 2 exit code. */ +DWORD sd_setup_elev_stage1(const sd_setup_elev_ops *ops, const wchar_t *base, const wchar_t *self, + const sd_setup_args *args, DWORD *child_exit); + +/* Stage 2 (elevated, in the stage folder). payload and expected come from this image's resources, after the caller checked + the table. Writes the progress and result lines to the pipe. Returns the exit code it reported (SD_SETUP_EXIT_*). */ +DWORD sd_setup_elev_stage2(const sd_setup_elev_ops *ops, const wchar_t *base, const wchar_t *self, + const sd_setup_args *args, const sd_setup_payload *payload, + const unsigned char expected[32]); + +/* Stage 2 of a per-machine uninstall (04 §6.7): no payload. It finds the ShellDrive product code in the machine registry + and runs "msiexec /x " in the stage folder, with the forwarded switches and the msi.log of the stage (sent over the + pipe as for an install). Returns the exit code it reported. */ +DWORD sd_setup_elev_stage2_uninstall(const sd_setup_elev_ops *ops, const wchar_t *base, const wchar_t *self, + const sd_setup_args *args); + +/* After an all-users install, the parent only (never the elevated stage): starts \shelldrive-ui.exe + as the logged-on user. Returns 0 when started; 1 when the folder is unknown, the parent holds an administrator token + (the UI would run elevated, so it is not started) or the start failed. */ +int sd_setup_elev_launch_installed(const sd_setup_elev_ops *ops); + +#endif /* SD_SETUP_ELEVATE_H */ diff --git a/apps/shelldrive-setup/setup_flow.c b/apps/shelldrive-setup/setup_flow.c new file mode 100644 index 0000000..537c44e --- /dev/null +++ b/apps/shelldrive-setup/setup_flow.c @@ -0,0 +1,118 @@ +/* shelldrive-setup: the page flow of the wizard (setup_flow.h). Pure: see the header for the rules. */ +#include "setup_flow.h" + +sd_flow_page sd_flow_first(const sd_flow_input *in) +{ + if (in->quiet) + return SD_FLOW_NONE; + if (in->uninstall) + return in->passive ? SD_FLOW_PROGRESS : SD_FLOW_UNINSTALL; + if (in->passive) + return SD_FLOW_PROGRESS; + return SD_FLOW_WELCOME; +} + +/* The page that follows the mode decision: the destination for a portable copy without a folder, the Dokany + page for the all-users choice, otherwise the progress page. */ +static sd_flow_page after_mode(const sd_flow_input *in, sd_setup_mode mode) +{ + if (mode == SD_SETUP_MODE_PORTABLE && !in->dest_given) + return SD_FLOW_DESTINATION; + if (mode == SD_SETUP_MODE_MACHINE) + return SD_FLOW_DOKANY; + return SD_FLOW_PROGRESS; +} + +sd_flow_page sd_flow_next(const sd_flow_input *in, const sd_flow_choice *c, sd_flow_page cur) +{ + sd_setup_mode mode = c != NULL ? c->mode : SD_SETUP_MODE_USER; + + switch (cur) { + case SD_FLOW_UNINSTALL: + return SD_FLOW_PROGRESS; + case SD_FLOW_WELCOME: + return SD_FLOW_LICENSE; + case SD_FLOW_LICENSE: + return in->mode_given ? after_mode(in, mode) : SD_FLOW_MODE; + case SD_FLOW_MODE: + return after_mode(in, mode); + case SD_FLOW_DESTINATION: + return mode == SD_SETUP_MODE_MACHINE ? SD_FLOW_DOKANY : SD_FLOW_PROGRESS; + case SD_FLOW_DOKANY: + return SD_FLOW_PROGRESS; + case SD_FLOW_PROGRESS: + /* The finish page follows an interactive install. A removal ends on its message box, a passive run has + no page after the progress. */ + if (in->uninstall || in->passive || in->quiet) + return SD_FLOW_NONE; + return SD_FLOW_FINISH; + case SD_FLOW_FINISH: + case SD_FLOW_NONE: + default: + return SD_FLOW_NONE; + } +} + +void sd_flow_dokany_view_of(sd_setup_dokany_plan plan, sd_flow_dokany_view *v) +{ + v->enabled = 0; + v->checked = 0; + v->has_note = 0; + v->note = 0; + switch (plan) { + case SD_SETUP_DOKANY_INSTALL_PLAN: + /* Pre-checked (D36); the user may uncheck it. */ + v->enabled = 1; + v->checked = 1; + break; + case SD_SETUP_DOKANY_UNAVAILABLE_PLAN: + /* Shown disabled and unchecked, with the reason (04 §7.3). */ + v->has_note = 1; + v->note = SD_MSG_INSTALLER_DOKANY_UNAVAILABLE; + break; + case SD_SETUP_DOKANY_SKIP_PLAN: + default: + /* Only /dokany=skip gets here for a machine install: the user may still check it. */ + v->enabled = 1; + v->has_note = 1; + v->note = SD_MSG_INSTALLER_DOKANY_SKIP_NOTE; + break; + } +} + +int sd_flow_page_title(sd_flow_page p, sd_msg_id *id) +{ + sd_msg_id m; + switch (p) { + case SD_FLOW_WELCOME: m = SD_MSG_INSTALLER_WELCOME_TITLE; break; + case SD_FLOW_LICENSE: m = SD_MSG_INSTALLER_LICENSE_TITLE; break; + case SD_FLOW_MODE: m = SD_MSG_INSTALLER_MODE_TITLE; break; + case SD_FLOW_DESTINATION: m = SD_MSG_INSTALLER_DEST_TITLE; break; + case SD_FLOW_DOKANY: m = SD_MSG_INSTALLER_DOKANY_TITLE; break; + case SD_FLOW_PROGRESS: m = SD_MSG_INSTALLER_WINDOW_TITLE; break; + case SD_FLOW_FINISH: m = SD_MSG_INSTALLER_FINISH_TITLE; break; + case SD_FLOW_UNINSTALL: m = SD_MSG_INSTALLER_UNINSTALL_TITLE; break; + case SD_FLOW_NONE: + default: + return 0; + } + *id = m; + return 1; +} + +const char *sd_flow_page_name(sd_flow_page p) +{ + switch (p) { + case SD_FLOW_WELCOME: return "welcome"; + case SD_FLOW_LICENSE: return "license"; + case SD_FLOW_MODE: return "mode"; + case SD_FLOW_DESTINATION: return "destination"; + case SD_FLOW_DOKANY: return "dokany"; + case SD_FLOW_PROGRESS: return "progress"; + case SD_FLOW_FINISH: return "finish"; + case SD_FLOW_UNINSTALL: return "uninstall"; + case SD_FLOW_NONE: + default: + return "none"; + } +} diff --git a/apps/shelldrive-setup/setup_flow.h b/apps/shelldrive-setup/setup_flow.h new file mode 100644 index 0000000..a6d8426 --- /dev/null +++ b/apps/shelldrive-setup/setup_flow.h @@ -0,0 +1,77 @@ +/* shelldrive-setup: the page flow of the wizard (docs/design/04 §6.3, §6.5, §7.3, §13). + * + * Pure: no window or process calls, so the tests (tests/setup) build this file on its own. The window + * (setup_ui.c) asks the flow for the page after the current one and shows what it returns; the answers + * the user gives (the mode, the folder) are passed back in sd_flow_choice before the next question. + * + * Which pages show, in short: + * /quiet no page at all (SD_FLOW_NONE first) + * /passive the progress page only + * /uninstall the removal confirmation, then progress + * install, interactive welcome, license, mode (unless /peruser, /portable or /permachine was given), + * destination (portable without a folder), Dokany (all users only), progress, finish + * + * The mode page offers the all-users choice (SD_FLOW_MACHINE_AVAILABLE = 1). The choice runs the two elevated + * stages (setup_elevate.c) and shows the parent's progress view; /permachine skips the mode page. */ +#ifndef SD_SETUP_FLOW_H +#define SD_SETUP_FLOW_H + +#include + +#include "setup_core.h" +#include "sd_l10n_installer_ids.h" + +/* The all-users choice is offered (04 §6.3). It needs the payload MSI at run time (packaging/msi, SD_BUILD_MSI). */ +#define SD_FLOW_MACHINE_AVAILABLE 1 + +typedef enum sd_flow_page { + SD_FLOW_NONE = 0, /* no more pages: the caller goes on with the work */ + SD_FLOW_WELCOME, + SD_FLOW_LICENSE, + SD_FLOW_MODE, + SD_FLOW_DESTINATION, + SD_FLOW_DOKANY, + SD_FLOW_PROGRESS, + SD_FLOW_FINISH, + SD_FLOW_UNINSTALL +} sd_flow_page; + +/* What the command line fixes. */ +typedef struct sd_flow_input { + int quiet; + int passive; + int uninstall; /* the removal (uninstall.exe, /uninstall) */ + int mode_given; /* /peruser, /permachine or /portable: the mode page is skipped */ + int dest_given; /* /portable=: the destination page is skipped */ +} sd_flow_input; + +/* What the user chose on the pages so far. mode is SD_SETUP_MODE_USER, _MACHINE or _PORTABLE. */ +typedef struct sd_flow_choice { + sd_setup_mode mode; +} sd_flow_choice; + +/* The page a run starts with: SD_FLOW_NONE for /quiet, SD_FLOW_PROGRESS for /passive. */ +sd_flow_page sd_flow_first(const sd_flow_input *in); + +/* The page after cur, given the choices so far. SD_FLOW_NONE after the last page. */ +sd_flow_page sd_flow_next(const sd_flow_input *in, const sd_flow_choice *c, sd_flow_page cur); + +/* The state of the Dokany checkbox for a plan (sd_setup_dokany_decide). note is the catalog message to show + under the checkbox, or 0 for none. */ +typedef struct sd_flow_dokany_view { + int enabled; + int checked; + int has_note; /* note is set; message id 0 is a real id (the window title), so a flag */ + sd_msg_id note; +} sd_flow_dokany_view; + +void sd_flow_dokany_view_of(sd_setup_dokany_plan plan, sd_flow_dokany_view *v); + +/* Catalog message of a page's title. Returns 1 with *id set, or 0 for a page without one (SD_FLOW_NONE). + Message id 0 is a real id (SD_MSG_INSTALLER_WINDOW_TITLE), so the result is a flag, not the id. */ +int sd_flow_page_title(sd_flow_page p, sd_msg_id *id); + +/* Short name of a page for the setup log and the test (pure ASCII). */ +const char *sd_flow_page_name(sd_flow_page p); + +#endif /* SD_SETUP_FLOW_H */ diff --git a/apps/shelldrive-setup/setup_host.c b/apps/shelldrive-setup/setup_host.c new file mode 100644 index 0000000..1a1ad1f --- /dev/null +++ b/apps/shelldrive-setup/setup_host.c @@ -0,0 +1,135 @@ +/* shelldrive-setup: running agents (docs/design/04 §6.9 step 1, 03 §2.1, C3). + Setup finds shelldrive-host.exe processes with Toolhelp (resolved at run time: not on the XP floor + list), reads each owner SID and session id, and sets the agent's manual-reset shutdown event + Global\com.librestatic.shelldrive.agent.shutdown... Pipes are never enumerated. */ +#include "setup_int.h" + +#include +#include +#include + +typedef HANDLE (WINAPI *sd_CreateToolhelp32Snapshot_t)(DWORD, DWORD); +typedef BOOL (WINAPI *sd_Process32FirstW_t)(HANDLE, PROCESSENTRY32W *); +typedef BOOL (WINAPI *sd_Process32NextW_t)(HANDLE, PROCESSENTRY32W *); + +#define SD_AGENT_EXE L"shelldrive-host.exe" +#define SD_AGENT_EVENT_PREFIX L"Global\\com.librestatic.shelldrive.agent.shutdown." + +/* SID string of the token owner of a process; 0 on success. */ +static int process_user_sid(DWORD pid, wchar_t *sid_out, size_t cch) +{ + HANDLE proc = OpenProcess(PROCESS_QUERY_INFORMATION, FALSE, pid); + HANDLE token = NULL; + BYTE buf[256]; + DWORD need = 0; + wchar_t *sid_text = NULL; + int rc = 1; + + if (proc == NULL) + return 1; + if (OpenProcessToken(proc, TOKEN_QUERY, &token) && GetTokenInformation(token, TokenUser, buf, sizeof(buf), &need)) { + TOKEN_USER *tu = (TOKEN_USER *)buf; + if (ConvertSidToStringSidW(tu->User.Sid, &sid_text)) { + if (SUCCEEDED(StringCchCopyW(sid_out, cch, sid_text))) + rc = 0; + LocalFree(sid_text); + } + } + if (token != NULL) + CloseHandle(token); + CloseHandle(proc); + return rc; +} + +/* Walks the agent processes. mode 0 counts those of owner (or the current user), mode 1 also signals + their shutdown events. Returns the number matched; the signalled count goes to *signalled. */ +static unsigned walk_agents(const wchar_t *owner, int signal, unsigned *signalled) +{ + sd_CreateToolhelp32Snapshot_t snap; + sd_Process32FirstW_t first; + sd_Process32NextW_t next; + HANDLE shot; + PROCESSENTRY32W pe; + unsigned matched = 0; + wchar_t mine[128]; + + snap = (sd_CreateToolhelp32Snapshot_t)sd_setup_proc(L"kernel32.dll", "CreateToolhelp32Snapshot"); + first = (sd_Process32FirstW_t)sd_setup_proc(L"kernel32.dll", "Process32FirstW"); + next = (sd_Process32NextW_t)sd_setup_proc(L"kernel32.dll", "Process32NextW"); + if (signalled != NULL) + *signalled = 0; + if (snap == NULL || first == NULL || next == NULL) + return 0; + + if (owner == NULL) { + if (sd_setup_current_sid(mine, SD_SETUP_COUNTOF(mine)) != 0) + return 0; + owner = mine; + } + + shot = snap(TH32CS_SNAPPROCESS, 0); + if (shot == INVALID_HANDLE_VALUE) + return 0; + memset(&pe, 0, sizeof(pe)); + pe.dwSize = sizeof(pe); + if (first(shot, &pe)) { + do { + wchar_t sid[128]; + DWORD session = 0; + if (_wcsicmp(pe.szExeFile, SD_AGENT_EXE) != 0) + continue; + if (process_user_sid(pe.th32ProcessID, sid, SD_SETUP_COUNTOF(sid)) != 0) + continue; + if (_wcsicmp(sid, owner) != 0) + continue; + matched++; + if (signal) { + wchar_t name[256]; + HANDLE ev; + if (!ProcessIdToSessionId(pe.th32ProcessID, &session)) + continue; + StringCchPrintfW(name, SD_SETUP_COUNTOF(name), L"%s%s.%lu", SD_AGENT_EVENT_PREFIX, sid, + (unsigned long)session); + ev = OpenEventW(EVENT_MODIFY_STATE, FALSE, name); + if (ev != NULL) { + if (SetEvent(ev) && signalled != NULL) + (*signalled)++; + CloseHandle(ev); + } + } + } while (next(shot, &pe)); + } + CloseHandle(shot); + return matched; +} + +int sd_setup_current_sid(wchar_t *out, size_t cch) +{ + return process_user_sid(GetCurrentProcessId(), out, cch); +} + +int sd_setup_signal_agents(const wchar_t *owner_sid, unsigned *signalled) +{ + unsigned n = 0; + walk_agents(owner_sid, 1, &n); + if (signalled != NULL) + *signalled = n; + return 0; +} + +int sd_setup_wait_agents(unsigned timeout_ms, unsigned *still) +{ + unsigned waited = 0; + unsigned n; + + for (;;) { + n = walk_agents(NULL, 0, NULL); + if (n == 0 || waited >= timeout_ms) + break; + Sleep(200); + waited += 200; + } + if (still != NULL) + *still = n; + return 0; +} diff --git a/apps/shelldrive-setup/setup_install.c b/apps/shelldrive-setup/setup_install.c new file mode 100644 index 0000000..3c8cdc1 --- /dev/null +++ b/apps/shelldrive-setup/setup_install.c @@ -0,0 +1,694 @@ +/* shelldrive-setup: install, repair, upgrade, portable extraction and uninstall for the account (per-user) + and portable modes (docs/design/04 §6.3, §6.7, §6.8, §6.9, §13). + + Per-user work never needs elevation: files go to %LOCALAPPDATA%\Programs\LibreStatic\ShellDrive (or + /installdir), registration to HKCU, the shortcut to the account's Programs folder. Per-machine installs + go through our MSI and the two elevated stages (04 §6.4); they are not in this build and report + SD_MSG_INSTALLER_ERROR_MACHINE_UNSUPPORTED. The payload is staged in a fresh temp folder first, so a + failed extraction never touches the installed files. */ +#include "setup_int.h" + +#include +#include +#include +#include + +#define SD_STATE_SUBKEY L"Software\\LibreStatic\\ShellDrive" +#define SD_UNINSTALL_SUBKEY L"Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\ShellDrive" +#define SD_DEFAULT_SUFFIX L"Programs\\LibreStatic\\ShellDrive" +#define SD_AGENT_WAIT_MS 15000u +#define SD_HOST_EXE L"shelldrive-host.exe" +#define SD_UI_EXE L"shelldrive-ui.exe" +#define SD_UNINSTALL_EXE L"uninstall.exe" +#define SD_SHORTCUT_NAME L"ShellDrive.lnk" +#define SD_PORTABLE_MARK L"shelldrive.portable" +#define SD_FILE_PERSISTENT_ACLS 0x00000008 + +#ifndef KEY_WOW64_64KEY +#define KEY_WOW64_64KEY 0x0100 +#endif +#ifndef KEY_WOW64_32KEY +#define KEY_WOW64_32KEY 0x0200 +#endif + +static void setup_logf(const char *event, const wchar_t *fmt, ...) +{ + wchar_t buf[1024]; + va_list ap; + va_start(ap, fmt); + StringCchVPrintfW(buf, SD_SETUP_COUNTOF(buf), fmt, ap); + va_end(ap); + sd_setup_log(event, buf); +} + +static void report(sd_setup_progress_fn fn, void *ctx, sd_msg_id stage, const wchar_t *folder, unsigned pct) +{ + if (fn != NULL) + fn(ctx, stage, folder, pct); +} + +static void strip_slash(wchar_t *s) +{ + size_t n = wcslen(s); + while (n > 0 && s[n - 1] == L'\\') + s[--n] = L'\0'; +} + +/* The view of the installed shell extension (its bitness): x86 flavours are 32-bit, the others native (KEY_WOW64_64KEY, + ignored on 32-bit Windows). The setup process is 32-bit on every x64 system, so the rows must name their view. */ +static REGSAM shell_access_for(sd_setup_flavour f) +{ + return (f == SD_SETUP_FL_X86_IA32 || f == SD_SETUP_FL_X86_SSE2) ? KEY_WOW64_32KEY : KEY_WOW64_64KEY; +} + +static int reg_read_sz(HKEY root, const wchar_t *sub, const wchar_t *name, wchar_t *out, DWORD cbytes) +{ + HKEY hk = NULL; + DWORD type = 0; + DWORD size = cbytes; + LONG rc; + + if (out == NULL || cbytes < sizeof(wchar_t)) + return 1; + rc = RegOpenKeyExW(root, sub, 0, KEY_QUERY_VALUE, &hk); + if (rc != ERROR_SUCCESS) + return 1; + rc = RegQueryValueExW(hk, name, NULL, &type, (BYTE *)out, &size); + RegCloseKey(hk); + if (rc != ERROR_SUCCESS || (type != REG_SZ && type != REG_EXPAND_SZ)) + return 1; + out[cbytes / sizeof(wchar_t) - 1] = L'\0'; + return 0; +} + +int sd_setup_registered_install_dir(wchar_t *out, size_t cch) +{ + wchar_t raw[MAX_PATH * 2]; + if (reg_read_sz(HKEY_CURRENT_USER, SD_STATE_SUBKEY, L"InstallDir", raw, sizeof(raw)) != 0) + return 1; + if (FAILED(StringCchCopyW(out, cch, raw))) + return 1; + strip_slash(out); + return 0; +} + +static int exists_key(HKEY root, const wchar_t *sub) +{ + HKEY hk = NULL; + if (RegOpenKeyExW(root, sub, 0, KEY_READ, &hk) != ERROR_SUCCESS) + return 0; + RegCloseKey(hk); + return 1; +} + +/* Default account install folder, or the requested one, without a trailing backslash. */ +static int resolve_user_dir(const wchar_t *requested, wchar_t *out, size_t cch) +{ + wchar_t base[MAX_PATH]; + + if (requested != NULL && requested[0] != L'\0') { + if (FAILED(StringCchCopyW(out, cch, requested))) + return 1; + strip_slash(out); + return 0; + } + if (sd_setup_special_folder(CSIDL_LOCAL_APPDATA, base, SD_SETUP_COUNTOF(base)) != 0) + return 1; + if (sd_setup_path_join(out, cch, base, SD_DEFAULT_SUFFIX) != 0) + return 1; + return 0; +} + +/* Start menu Programs folder of the account, or the test seam. */ +static int resolve_programs_dir(const wchar_t *requested, wchar_t *out, size_t cch) +{ + if (requested != NULL && requested[0] != L'\0') + return FAILED(StringCchCopyW(out, cch, requested)) ? 1 : 0; + return sd_setup_special_folder(CSIDL_PROGRAMS, out, cch); +} + +/* Wait for running agents of this account. Returns 0 when setup may go on. */ +static int stop_agents(int force, int skip, sd_setup_progress_fn fn, void *ctx, sd_setup_result *r) +{ + unsigned signalled = 0, still = 0; + + if (skip) + return 0; + report(fn, ctx, SD_MSG_INSTALLER_PROGRESS_STOP, NULL, 10); + sd_setup_signal_agents(NULL, &signalled); + if (signalled > 0) + setup_logf("agents.signalled", L"%u agent(s) asked to exit", signalled); + sd_setup_wait_agents(SD_AGENT_WAIT_MS, &still); + if (still > 0) { + setup_logf("agents.running", L"%u agent(s) still running after %u ms", still, SD_AGENT_WAIT_MS); + if (!force) { + sd_setup_fail(r, SD_SETUP_EXIT_FATAL, SD_MSG_INSTALLER_ERROR_RUNNING, 0); + return 1; + } + } + return 0; +} + +/* Extracts the payload into a fresh staging folder under %TEMP% and returns its path in staging. */ +static int make_staging(wchar_t *staging, size_t cch, sd_setup_result *r) +{ + wchar_t temp[MAX_PATH]; + wchar_t hex[40]; + wchar_t leaf[64]; + + if (sd_setup_temp_dir(temp, SD_SETUP_COUNTOF(temp)) != 0 || sd_setup_random_hex(hex, SD_SETUP_COUNTOF(hex)) != 0) + return sd_setup_fail(r, SD_SETUP_EXIT_FATAL, SD_MSG_INSTALLER_ERROR_WRITE, (long)GetLastError()); + StringCchPrintfW(leaf, SD_SETUP_COUNTOF(leaf), L"ShellDrive-setup-%s", hex); + if (sd_setup_path_join(staging, cch, temp, leaf) != 0) + return sd_setup_fail(r, SD_SETUP_EXIT_FATAL, SD_MSG_INSTALLER_ERROR_WRITE, 0); + if (!CreateDirectoryW(staging, NULL)) + return sd_setup_fail(r, SD_SETUP_EXIT_FATAL, SD_MSG_INSTALLER_ERROR_WRITE, (long)GetLastError()); + return 0; +} + +/* Moves src over dst. A file that is in use (a running agent, a loaded shell extension) is renamed aside + to dst.sd-old- first; NTFS allows renaming a loaded image (04 §6.9 step 3). */ +static DWORD move_over(const wchar_t *src, const wchar_t *dst, unsigned *aside) +{ + DWORD err; + unsigned n; + + if (MoveFileExW(src, dst, MOVEFILE_REPLACE_EXISTING | MOVEFILE_COPY_ALLOWED)) + return 0; + err = GetLastError(); + if (GetFileAttributesW(dst) == INVALID_FILE_ATTRIBUTES) + return err; + + for (n = 1; n < 100; n++) { + wchar_t old[MAX_PATH]; + StringCchPrintfW(old, SD_SETUP_COUNTOF(old), L"%s.sd-old-%u", dst, n); + if (GetFileAttributesW(old) != INVALID_FILE_ATTRIBUTES) + continue; + if (!MoveFileExW(dst, old, 0)) + return GetLastError(); + if (aside != NULL) + (*aside)++; + if (MoveFileExW(src, dst, MOVEFILE_REPLACE_EXISTING | MOVEFILE_COPY_ALLOWED)) + return 0; + return GetLastError(); + } + return err; +} + +/* Moves every member of the staging tree into dst, recursively. */ +static int place_tree(const wchar_t *src, const wchar_t *dst, unsigned *files, unsigned long long *bytes, + unsigned *aside, sd_setup_result *r) +{ + WIN32_FIND_DATAW fd; + HANDLE find; + wchar_t pattern[MAX_PATH]; + + if (sd_setup_mkdirs(dst) != 0) + return sd_setup_fail(r, SD_SETUP_EXIT_FATAL, SD_MSG_INSTALLER_ERROR_WRITE, 0); + if (sd_setup_path_join(pattern, MAX_PATH, src, L"*") != 0) + return sd_setup_fail(r, SD_SETUP_EXIT_FATAL, SD_MSG_INSTALLER_ERROR_WRITE, 0); + find = FindFirstFileW(pattern, &fd); + if (find == INVALID_HANDLE_VALUE) + return 0; + do { + wchar_t from[MAX_PATH], to[MAX_PATH]; + DWORD err; + if (wcscmp(fd.cFileName, L".") == 0 || wcscmp(fd.cFileName, L"..") == 0) + continue; + if (sd_setup_path_join(from, MAX_PATH, src, fd.cFileName) != 0 || + sd_setup_path_join(to, MAX_PATH, dst, fd.cFileName) != 0) { + FindClose(find); + return sd_setup_fail(r, SD_SETUP_EXIT_FATAL, SD_MSG_INSTALLER_ERROR_WRITE, 0); + } + if (fd.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) { + int rc = place_tree(from, to, files, bytes, aside, r); + if (rc != 0) { + FindClose(find); + return rc; + } + continue; + } + err = move_over(from, to, aside); + if (err != 0) { + FindClose(find); + setup_logf("file.replace", L"%s (error %lu)", to, (unsigned long)err); + if (r != NULL) + StringCchCopyW(r->text, SD_SETUP_COUNTOF(r->text), fd.cFileName); + return sd_setup_fail(r, SD_SETUP_EXIT_FATAL, SD_MSG_INSTALLER_ERROR_FILE_LOCKED, (long)err); + } + (*files)++; + if (bytes != NULL) + *bytes += ((unsigned long long)fd.nFileSizeHigh << 32) | fd.nFileSizeLow; + } while (FindNextFileW(find, &fd)); + FindClose(find); + return 0; +} + +/* Total size of the files under dir, in bytes. */ +static unsigned long long tree_bytes(const wchar_t *dir, int depth) +{ + WIN32_FIND_DATAW fd; + HANDLE find; + wchar_t pattern[MAX_PATH]; + unsigned long long total = 0; + + if (depth > 16 || sd_setup_path_join(pattern, MAX_PATH, dir, L"*") != 0) + return 0; + find = FindFirstFileW(pattern, &fd); + if (find == INVALID_HANDLE_VALUE) + return 0; + do { + wchar_t child[MAX_PATH]; + if (wcscmp(fd.cFileName, L".") == 0 || wcscmp(fd.cFileName, L"..") == 0) + continue; + if (sd_setup_path_join(child, MAX_PATH, dir, fd.cFileName) != 0) + continue; + if (fd.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) + total += tree_bytes(child, depth + 1); + else + total += ((unsigned long long)fd.nFileSizeHigh << 32) | fd.nFileSizeLow; + } while (FindNextFileW(find, &fd)); + FindClose(find); + return total; +} + +static int folder_has_entries(const wchar_t *dir) +{ + WIN32_FIND_DATAW fd; + HANDLE find; + wchar_t pattern[MAX_PATH]; + int any = 0; + + if (sd_setup_path_join(pattern, MAX_PATH, dir, L"*") != 0) + return 1; + find = FindFirstFileW(pattern, &fd); + if (find == INVALID_HANDLE_VALUE) + return 0; + do { + if (wcscmp(fd.cFileName, L".") != 0 && wcscmp(fd.cFileName, L"..") != 0) + any = 1; + } while (!any && FindNextFileW(find, &fd)); + FindClose(find); + return any; +} + +/* Runs the shelldrive-host hook of the installed copy (04 §8.3). Failures are logged, not fatal here. */ +static void run_host_hook(const wchar_t *install_dir, const wchar_t *hook) +{ + wchar_t exe[MAX_PATH]; + wchar_t cmd[MAX_PATH * 2]; + STARTUPINFOW si; + PROCESS_INFORMATION pi; + DWORD code = 0; + + if (sd_setup_path_join(exe, MAX_PATH, install_dir, SD_HOST_EXE) != 0) + return; + if (GetFileAttributesW(exe) == INVALID_FILE_ATTRIBUTES) { + setup_logf("hook.skipped", L"%s not found", exe); + return; + } + StringCchPrintfW(cmd, SD_SETUP_COUNTOF(cmd), L"\"%s\" --installer-hook %s --apply", exe, hook); + memset(&si, 0, sizeof(si)); + si.cb = sizeof(si); + memset(&pi, 0, sizeof(pi)); + if (!CreateProcessW(exe, cmd, NULL, NULL, FALSE, CREATE_NO_WINDOW, NULL, install_dir, &si, &pi)) { + sd_setup_log_error("hook.start", cmd, GetLastError()); + return; + } + WaitForSingleObject(pi.hProcess, 60000); + GetExitCodeProcess(pi.hProcess, &code); + setup_logf("hook.done", L"%s exit %lu", hook, (unsigned long)code); + CloseHandle(pi.hThread); + CloseHandle(pi.hProcess); +} + +static void fill_done_fields(sd_setup_result *r, const wchar_t *folder, const wchar_t *version, unsigned files) +{ + StringCchCopyW(r->folder, SD_SETUP_COUNTOF(r->folder), folder); + StringCchCopyW(r->version, SD_SETUP_COUNTOF(r->version), version); + r->count = files; +} + +static void finish_log(sd_setup_result *r) +{ + if (r->exit_code != SD_SETUP_EXIT_OK && sd_setup_log_path()[0] != L'\0') { + StringCchCopyW(r->log_path, SD_SETUP_COUNTOF(r->log_path), sd_setup_log_path()); + } +} + +/* Staged copy of the payload, then move into dst. Shared by per-user and portable installs. */ +static int stage_and_place(const sd_setup_install_opts *o, const wchar_t *dst, sd_setup_result *r, + unsigned *files, unsigned long long *bytes) +{ + wchar_t staging[MAX_PATH]; + wchar_t msi[MAX_PATH]; + unsigned extracted = 0; + unsigned aside = 0; + int rc; + + rc = make_staging(staging, SD_SETUP_COUNTOF(staging), r); + if (rc != 0) + return rc; + + rc = sd_setup_cab_extract(o->payload, staging, NULL, r, &extracted); + report(o->progress, o->progress_ctx, SD_MSG_INSTALLER_PROGRESS_EXTRACT, NULL, 40); + if (rc != 0) { + sd_setup_remove_tree(staging, NULL); + return rc; + } + setup_logf("payload.extracted", L"%u member(s) into staging", extracted); + + /* The MSI is the all-users package (stage 2 of the elevated install only): it never lands in a per-user or portable + folder. */ + if (sd_setup_path_join(msi, MAX_PATH, staging, L"ShellDrive.msi") == 0) + DeleteFileW(msi); + + report(o->progress, o->progress_ctx, SD_MSG_INSTALLER_PROGRESS_COPY, dst, 60); + rc = place_tree(staging, dst, files, bytes, &aside, r); + if (aside > 0) + setup_logf("file.aside", L"%u file(s) renamed aside (in use)", aside); + sd_setup_remove_tree(staging, NULL); + return rc; +} + +static int verify_payload(const sd_setup_install_opts *o, sd_setup_result *r) +{ + unsigned char sha[32]; + if (o->expected_sha256 == NULL) + return 0; + sd_setup_sha256(o->payload->data, o->payload->size, sha); + if (memcmp(sha, o->expected_sha256, 32) != 0) { + setup_logf("payload.hash_mismatch", L"the embedded package does not match its recorded SHA-256"); + return sd_setup_fail(r, SD_SETUP_EXIT_FATAL, SD_MSG_INSTALLER_ERROR_PAYLOAD_DAMAGED, 0); + } + return 0; +} + +static int install_user(const sd_setup_install_opts *o, sd_setup_result *r) +{ + wchar_t dir[MAX_PATH]; + wchar_t installed[64] = L""; + wchar_t programs[MAX_PATH]; + wchar_t lnk[MAX_PATH]; + wchar_t target[MAX_PATH]; + const wchar_t *version = o->version != NULL ? o->version : L"0.0.0"; + unsigned files = 0; + unsigned long long bytes = 0; + sd_setup_regctx rc; + int rv; + + if (resolve_user_dir(o->install_dir, dir, SD_SETUP_COUNTOF(dir)) != 0) + return sd_setup_fail(r, SD_SETUP_EXIT_FATAL, SD_MSG_INSTALLER_ERROR_WRITE, 0); + + if (exists_key(HKEY_LOCAL_MACHINE, SD_UNINSTALL_SUBKEY)) { + setup_logf("install.refused", L"a per-machine ShellDrive is registered"); + return sd_setup_fail(r, SD_SETUP_EXIT_FATAL, SD_MSG_INSTALLER_ERROR_MACHINE_EXISTS, 0); + } + + report(o->progress, o->progress_ctx, SD_MSG_INSTALLER_PROGRESS_CHECK, NULL, 5); + if (reg_read_sz(HKEY_CURRENT_USER, SD_STATE_SUBKEY, L"Version", installed, sizeof(installed)) == 0) { + int cmp = sd_setup_version_compare(installed, version); + setup_logf("install.previous", L"installed %s, this setup %s", installed, version); + if (cmp > 0 && !o->update) { + StringCchCopyW(r->version, SD_SETUP_COUNTOF(r->version), installed); + return sd_setup_fail(r, SD_SETUP_EXIT_NEWER, SD_MSG_INSTALLER_ERROR_NEWER_INSTALLED, 0); + } + setup_logf("install.kind", cmp == 0 ? L"repair (same version)" : (cmp < 0 ? L"upgrade" : L"downgrade (/update)")); + } else { + setup_logf("install.kind", L"new install"); + } + + if (stop_agents(o->force, o->skip_agents, o->progress, o->progress_ctx, r) != 0) + return r->exit_code; + + rv = verify_payload(o, r); + if (rv != 0) + return rv; + + rv = stage_and_place(o, dir, r, &files, &bytes); + if (rv != 0) + return rv; + bytes = tree_bytes(dir, 0); + + report(o->progress, o->progress_ctx, SD_MSG_INSTALLER_PROGRESS_REGISTER, NULL, 80); + memset(&rc, 0, sizeof(rc)); + rc.root = HKEY_CURRENT_USER; + { + wchar_t slashed[MAX_PATH]; + wchar_t x86dir[MAX_PATH]; + wchar_t x86dll[MAX_PATH]; + sd_setup_host host; + + if (sd_setup_dir_with_slash(slashed, SD_SETUP_COUNTOF(slashed), dir) != 0) + return sd_setup_fail(r, SD_SETUP_EXIT_FATAL, SD_MSG_INSTALLER_ERROR_WRITE, 0); + rc.install_dir = slashed; + rc.version = version; + rc.flavour = sd_setup_flavour_name(o->flavour); + rc.install_bytes = bytes; + rc.autostart = o->autostart == 1; + rc.shell_access = shell_access_for(o->flavour); + + /* The x86 copy lives in x86\ under the install folder (per-user has no Program Files (x86), 03 §8.5). It is + registered in the 32-bit view only where the views are split and the copy came with the payload. */ + sd_setup_detect_host(&host); + if (StringCchPrintfW(x86dir, SD_SETUP_COUNTOF(x86dir), L"%sx86\\", slashed) == S_OK && + sd_setup_path_join(x86dll, MAX_PATH, dir, L"x86\\shelldrive-shellext.dll") == 0 && + sd_setup_shell32_wanted(o->flavour, &host) && GetFileAttributesW(x86dll) != INVALID_FILE_ATTRIBUTES) { + rc.install_dir32 = x86dir; + setup_logf("shellext.32", L"x86 copy registered in the 32-bit view: %s", x86dll); + } else if (sd_setup_shell32_wanted(o->flavour, &host)) { + setup_logf("shellext.32", L"no x86 copy in the payload: the 32-bit view is not registered"); + } else { + setup_logf("shellext.32", L"the 32-bit view is not registered (shared views or no x86 copy in this flavour)"); + } + if (sd_setup_reg_apply(&rc) != 0) + return sd_setup_fail(r, SD_SETUP_EXIT_FATAL, SD_MSG_INSTALLER_ERROR_WRITE, 0); + } + + if (!o->skip_shortcut) { + report(o->progress, o->progress_ctx, SD_MSG_INSTALLER_PROGRESS_SHORTCUT, NULL, 90); + if (resolve_programs_dir(o->programs_dir, programs, SD_SETUP_COUNTOF(programs)) == 0 && + sd_setup_path_join(lnk, MAX_PATH, programs, SD_SHORTCUT_NAME) == 0 && + sd_setup_path_join(target, MAX_PATH, dir, SD_UI_EXE) == 0) { + if (sd_setup_shortcut_create(lnk, target, dir, L"ShellDrive", target) != 0) + setup_logf("shortcut.failed", L"could not create %s", lnk); + else + setup_logf("shortcut.created", L"%s", lnk); + } + } + + fill_done_fields(r, dir, version, files); + r->exit_code = SD_SETUP_EXIT_OK; + r->msg = SD_MSG_INSTALLER_DONE_INSTALL; + setup_logf("install.done", L"ShellDrive %s in %s, %u file(s), %I64u bytes", version, dir, files, bytes); + report(o->progress, o->progress_ctx, 0, dir, 100); + return 0; +} + +static int install_portable(const sd_setup_install_opts *o, sd_setup_result *r) +{ + wchar_t dir[MAX_PATH]; + wchar_t data[MAX_PATH]; + wchar_t mark[MAX_PATH]; + wchar_t root[8]; + unsigned files = 0; + unsigned long long bytes = 0; + DWORD flags = 0; + HANDLE h; + int rv; + + if (o->portable_dir == NULL || o->portable_dir[0] == L'\0') + return sd_setup_fail(r, SD_SETUP_EXIT_FATAL, SD_MSG_INSTALLER_ERROR_WRITE, 0); + if (FAILED(StringCchCopyW(dir, SD_SETUP_COUNTOF(dir), o->portable_dir))) + return sd_setup_fail(r, SD_SETUP_EXIT_FATAL, SD_MSG_INSTALLER_ERROR_WRITE, 0); + strip_slash(dir); + + if (GetFileAttributesW(dir) != INVALID_FILE_ATTRIBUTES && folder_has_entries(dir)) { + StringCchCopyW(r->folder, SD_SETUP_COUNTOF(r->folder), dir); + return sd_setup_fail(r, SD_SETUP_EXIT_FATAL, SD_MSG_INSTALLER_ERROR_PORTABLE_NOT_EMPTY, 0); + } + + rv = verify_payload(o, r); + if (rv != 0) + return rv; + + /* Drives without persistent ACLs (FAT, exFAT, most USB sticks) get a warning (04 §6.8). */ + if (dir[1] == L':') { + StringCchPrintfW(root, SD_SETUP_COUNTOF(root), L"%c:\\", dir[0]); + if (GetVolumeInformationW(root, NULL, 0, NULL, NULL, &flags, NULL, 0) && !(flags & SD_FILE_PERSISTENT_ACLS)) { + r->warn_volume = 1; + setup_logf("portable.volume", L"%s has no persistent ACLs", root); + } + } + + report(o->progress, o->progress_ctx, SD_MSG_INSTALLER_PROGRESS_PORTABLE, dir, 20); + rv = stage_and_place(o, dir, r, &files, &bytes); + if (rv != 0) + return rv; + + /* A portable copy carries no uninstaller (04 §6.8: no registration of any kind). */ + if (sd_setup_path_join(mark, MAX_PATH, dir, SD_UNINSTALL_EXE) == 0) + DeleteFileW(mark); + if (sd_setup_path_join(data, MAX_PATH, dir, L"data") == 0) + sd_setup_mkdirs(data); + if (sd_setup_path_join(mark, MAX_PATH, dir, SD_PORTABLE_MARK) == 0) { + h = CreateFileW(mark, GENERIC_WRITE, 0, NULL, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL); + if (h != INVALID_HANDLE_VALUE) + CloseHandle(h); + else + return sd_setup_fail(r, SD_SETUP_EXIT_FATAL, SD_MSG_INSTALLER_ERROR_WRITE, (long)GetLastError()); + } + + fill_done_fields(r, dir, o->version != NULL ? o->version : L"0.0.0", files); + r->exit_code = SD_SETUP_EXIT_OK; + r->msg = SD_MSG_INSTALLER_DONE_PORTABLE; + setup_logf("portable.done", L"%s, %u file(s)", dir, files); + report(o->progress, o->progress_ctx, 0, dir, 100); + return 0; +} + +static void open_log_for(const wchar_t *requested) +{ + wchar_t path[MAX_PATH]; + wchar_t temp[MAX_PATH]; + SYSTEMTIME st; + + if (requested != NULL) { + sd_setup_log_open(requested); + return; + } + if (sd_setup_temp_dir(temp, SD_SETUP_COUNTOF(temp)) != 0) + return; + GetLocalTime(&st); + StringCchPrintfW(path, SD_SETUP_COUNTOF(path), L"%sShellDrive-setup-%04u%02u%02u-%02u%02u%02u.log", temp, + (unsigned)st.wYear, (unsigned)st.wMonth, (unsigned)st.wDay, (unsigned)st.wHour, + (unsigned)st.wMinute, (unsigned)st.wSecond); + sd_setup_log_open(path); +} + +int sd_setup_install(const sd_setup_install_opts *o, sd_setup_result *r) +{ + sd_setup_result local; + sd_setup_host host; + + if (r == NULL) + r = &local; + memset(r, 0, sizeof(*r)); + r->exit_code = SD_SETUP_EXIT_FATAL; + + open_log_for(o->log_path); + sd_setup_detect_host(&host); + setup_logf("setup.start", L"mode=%d flavour=%s version=%s native=0x%04x sse2=%d os=%u.%u admin=%d", + (int)o->mode, sd_setup_flavour_name(o->flavour) ? sd_setup_flavour_name(o->flavour) : L"none", + o->version ? o->version : L"0.0.0", (unsigned)host.native_machine, host.sse2, host.os_major, host.os_minor, + host.is_admin); + + if (o->payload == NULL || o->payload->data == NULL || o->flavour == SD_SETUP_FL_NONE) { + sd_setup_fail(r, SD_SETUP_EXIT_FATAL, SD_MSG_INSTALLER_ERROR_PAYLOAD_MISSING, 0); + if (sd_setup_flavour_name(o->flavour) != NULL) + StringCchCopyW(r->text, SD_SETUP_COUNTOF(r->text), sd_setup_flavour_name(o->flavour)); + } else if (o->mode == SD_SETUP_MODE_MACHINE) { + sd_setup_fail(r, SD_SETUP_EXIT_FATAL, SD_MSG_INSTALLER_ERROR_MACHINE_UNSUPPORTED, 0); + } else if (o->mode == SD_SETUP_MODE_PORTABLE) { + install_portable(o, r); + } else { + install_user(o, r); + } + + if (r->exit_code != SD_SETUP_EXIT_OK) { + if (r->msg != 0) + setup_logf("setup.failed", L"message %lu, code %ld", (unsigned long)r->msg, r->code); + else + setup_logf("setup.failed", L"exit %d", r->exit_code); + } + finish_log(r); + sd_setup_log_close(); + return r->exit_code; +} + +/* ------------------------------------------------------------------ uninstall */ + +int sd_setup_uninstall(const sd_setup_uninstall_opts *o, sd_setup_result *r) +{ + sd_setup_result local; + wchar_t dir[MAX_PATH]; + wchar_t registered[MAX_PATH * 2]; + wchar_t programs[MAX_PATH]; + wchar_t lnk[MAX_PATH]; + wchar_t cache[MAX_PATH]; + wchar_t local_app[MAX_PATH]; + wchar_t roaming[MAX_PATH]; + unsigned failed = 0; + sd_setup_regctx rc; + + if (r == NULL) + r = &local; + memset(r, 0, sizeof(*r)); + r->exit_code = SD_SETUP_EXIT_FATAL; + + open_log_for(o->log_path); + setup_logf("uninstall.start", L"force=%d", o->force); + + if (o->install_dir != NULL && o->install_dir[0] != L'\0') { + StringCchCopyW(dir, SD_SETUP_COUNTOF(dir), o->install_dir); + strip_slash(dir); + } else if (reg_read_sz(HKEY_CURRENT_USER, SD_STATE_SUBKEY, L"InstallDir", registered, sizeof(registered)) == 0) { + StringCchCopyW(dir, SD_SETUP_COUNTOF(dir), registered); + strip_slash(dir); + } else { + sd_setup_fail(r, SD_SETUP_EXIT_FATAL, SD_MSG_INSTALLER_ERROR_NOT_INSTALLED, 0); + finish_log(r); + sd_setup_log_close(); + return r->exit_code; + } + StringCchCopyW(r->folder, SD_SETUP_COUNTOF(r->folder), dir); + + if (stop_agents(o->force, o->skip_agents, o->progress, o->progress_ctx, r) != 0) { + finish_log(r); + sd_setup_log_close(); + return r->exit_code; + } + + report(o->progress, o->progress_ctx, SD_MSG_INSTALLER_PROGRESS_UNINSTALL, dir, 30); + if (!o->skip_hooks) + run_host_hook(dir, L"user-cleanup"); + + memset(&rc, 0, sizeof(rc)); + rc.root = HKEY_CURRENT_USER; + /* The view of the shell rows follows the installed flavour, not this (32-bit) uninstaller (04 §6.2). */ + { + wchar_t flavour[32]; + rc.shell_access = KEY_WOW64_64KEY; + if (reg_read_sz(HKEY_CURRENT_USER, SD_STATE_SUBKEY, L"Flavour", flavour, sizeof(flavour)) == 0) + rc.shell_access = shell_access_for(sd_setup_flavour_parse(flavour)); + } + sd_setup_reg_remove(&rc); + setup_logf("uninstall.registration", L"account registration removed"); + + if (!o->skip_shortcut && resolve_programs_dir(o->programs_dir, programs, SD_SETUP_COUNTOF(programs)) == 0 && + sd_setup_path_join(lnk, MAX_PATH, programs, SD_SHORTCUT_NAME) == 0) { + DeleteFileW(lnk); + } + + /* Caches are always removed (TM-R28, 04 §6.7); settings and profiles are kept. */ + if (sd_setup_special_folder(CSIDL_LOCAL_APPDATA, local_app, SD_SETUP_COUNTOF(local_app)) == 0 && + sd_setup_path_join(cache, MAX_PATH, local_app, L"LibreStatic\\ShellDrive\\cache") == 0) { + sd_setup_remove_tree(cache, &failed); + } + + report(o->progress, o->progress_ctx, SD_MSG_INSTALLER_PROGRESS_UNINSTALL, dir, 70); + sd_setup_remove_tree(dir, &failed); + if (failed > 0) + setup_logf("uninstall.incomplete", L"%u item(s) could not be removed", failed); + + if (sd_setup_special_folder(CSIDL_APPDATA, roaming, SD_SETUP_COUNTOF(roaming)) == 0) + setup_logf("uninstall.kept", L"settings and profiles kept under %s\\LibreStatic\\ShellDrive", roaming); + + r->exit_code = failed > 0 ? SD_SETUP_EXIT_REBOOT : SD_SETUP_EXIT_OK; + r->msg = SD_MSG_INSTALLER_DONE_UNINSTALL; + setup_logf("uninstall.done", L"exit %d", r->exit_code); + report(o->progress, o->progress_ctx, 0, dir, 100); + finish_log(r); + sd_setup_log_close(); + return r->exit_code; +} diff --git a/apps/shelldrive-setup/setup_int.h b/apps/shelldrive-setup/setup_int.h new file mode 100644 index 0000000..f07ecbf --- /dev/null +++ b/apps/shelldrive-setup/setup_int.h @@ -0,0 +1,54 @@ +/* shelldrive-setup engine: helpers shared by the engine's .c files. Not part of the public interface. */ +#ifndef SD_SETUP_INT_H +#define SD_SETUP_INT_H + +#include +#include +#include + +#include "setup_core.h" +#include "sd_l10n_installer_ids.h" + +#define SD_SETUP_COUNTOF(a) (sizeof(a) / sizeof((a)[0])) + +/* Resolves an export at run time (04 §2.4). module is a loaded or KnownDLL name such as L"kernel32.dll". + Returns NULL when absent (older Windows). Loads the module with its full System32 path when it is not + loaded yet, so no search path is involved (04 §6.4 rule 3). */ +FARPROC sd_setup_proc(const wchar_t *module, const char *name); + +/* Full path of a System32 file, e.g. "cabinet.dll" -> "C:\Windows\System32\cabinet.dll". */ +int sd_setup_system_path(const wchar_t *file, wchar_t *out, size_t cch); + +/* Path helpers. dst gets dir with exactly one trailing backslash, then the other parts. */ +int sd_setup_path_join(wchar_t *dst, size_t cch, const wchar_t *dir, const wchar_t *name); +int sd_setup_dir_with_slash(wchar_t *dst, size_t cch, const wchar_t *dir); + +/* Special folders (SHGetFolderPathW): csidl is CSIDL_LOCAL_APPDATA, CSIDL_APPDATA, ... */ +int sd_setup_special_folder(int csidl, wchar_t *out, size_t cch); + +/* Unique name for a new folder or file: 32 hex digits from CryptGenRandom. */ +int sd_setup_random_hex(wchar_t *out, size_t cch); + +/* Temp folder path with a trailing backslash. */ +int sd_setup_temp_dir(wchar_t *out, size_t cch); + +/* Recursively deletes a folder without following reparse points (junctions are removed, not walked). + Files that cannot be deleted are counted in *failed; the call still returns 0. */ +int sd_setup_remove_tree(const wchar_t *dir, unsigned *failed); + +/* Creates dir and its missing parents. Returns 0 or the Windows error. */ +DWORD sd_setup_mkdirs(const wchar_t *dir); + +/* ASCII (CAB member names) to wide. Returns 0 on success. */ +int sd_setup_ascii_to_wide(const char *src, wchar_t *dst, size_t cch); + +/* Sets r->exit_code and r->msg, plus r->code when code != 0. Returns exit_code. */ +int sd_setup_fail(sd_setup_result *r, int exit_code, sd_msg_id msg, long code); + +/* Log line helper for file operations: event "file.copy", text " -> ". */ +void sd_setup_log_error(const char *event, const wchar_t *what, DWORD err); + +/* Value helpers for the registration rows. */ +int sd_setup_sha256_hex(const unsigned char sha[32], char out[65]); + +#endif /* SD_SETUP_INT_H */ diff --git a/apps/shelldrive-setup/setup_keyring.inc b/apps/shelldrive-setup/setup_keyring.inc new file mode 100644 index 0000000..8af6031 --- /dev/null +++ b/apps/shelldrive-setup/setup_keyring.inc @@ -0,0 +1,4 @@ +/* setup_keyring.inc: keyring compiled into the stub (see setup_payload_sig.c). Generated by the release pipeline from + * installer/keys/keyset.txt (04 §9.3). The repository holds no release keys: this placeholder is revoked, so no payload + * verifies until the pipeline generates the file. Entry: { { 32 public key bytes }, "stable"|"beta", valid_from, valid_to, revoked }. */ +{ { 0 }, "stable", 0, 0, 1 }, diff --git a/apps/shelldrive-setup/setup_log.c b/apps/shelldrive-setup/setup_log.c new file mode 100644 index 0000000..79d2c9b --- /dev/null +++ b/apps/shelldrive-setup/setup_log.c @@ -0,0 +1,62 @@ +/* shelldrive-setup: the setup log (docs/design/04 §13). English lines, UTF-8, one file per run. The + engine runs on one thread, so the log handle is process state. Failures to log never stop setup. */ +#include "setup_int.h" + +#include + +static HANDLE g_log = INVALID_HANDLE_VALUE; +static wchar_t g_log_path[MAX_PATH]; + +int sd_setup_log_open(const wchar_t *path) +{ + HANDLE h; + + if (g_log != INVALID_HANDLE_VALUE) { + CloseHandle(g_log); + g_log = INVALID_HANDLE_VALUE; + } + g_log_path[0] = L'\0'; + if (path == NULL || path[0] == L'\0') + return 1; + h = CreateFileW(path, GENERIC_WRITE, FILE_SHARE_READ, NULL, OPEN_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL); + if (h == INVALID_HANDLE_VALUE) + return 1; + SetFilePointer(h, 0, NULL, FILE_END); + g_log = h; + StringCchCopyW(g_log_path, MAX_PATH, path); + return 0; +} + +const wchar_t *sd_setup_log_path(void) +{ + return g_log_path; +} + +/* One line: "YYYY-MM-DD HH:MM:SS \r\n", the text converted to UTF-8. */ +void sd_setup_log(const char *event, const wchar_t *text) +{ + SYSTEMTIME st; + char line[2048]; + char utf8[1536]; + char stamp[32]; + DWORD written = 0; + + if (g_log == INVALID_HANDLE_VALUE) + return; + GetLocalTime(&st); + StringCchPrintfA(stamp, sizeof(stamp), "%04u-%02u-%02u %02u:%02u:%02u ", + (unsigned)st.wYear, (unsigned)st.wMonth, (unsigned)st.wDay, + (unsigned)st.wHour, (unsigned)st.wMinute, (unsigned)st.wSecond); + if (WideCharToMultiByte(CP_UTF8, 0, text != NULL ? text : L"", -1, utf8, (int)sizeof(utf8), NULL, NULL) <= 0) + utf8[0] = '\0'; + StringCchPrintfA(line, sizeof(line), "%s%s %s\r\n", stamp, event != NULL ? event : "event", utf8); + WriteFile(g_log, line, (DWORD)strlen(line), &written, NULL); +} + +void sd_setup_log_close(void) +{ + if (g_log != INVALID_HANDLE_VALUE) + CloseHandle(g_log); + g_log = INVALID_HANDLE_VALUE; + g_log_path[0] = L'\0'; +} diff --git a/apps/shelldrive-setup/setup_main.c b/apps/shelldrive-setup/setup_main.c new file mode 100644 index 0000000..a4811f6 --- /dev/null +++ b/apps/shelldrive-setup/setup_main.c @@ -0,0 +1,691 @@ +/* shelldrive-setup: the setup stub (docs/design/04 §6.1-§6.9, TM-R33-R35). + + Entry point rules: the first statement restricts DLL search (SetDefaultDllDirectories where it exists, + else SetDllDirectoryW(L"")); the only static imports are KnownDLLs (checked by sd-importcheck, TST-30). + The payload comes from the RT_RCDATA resource of the flavour the host needs, and its SHA-256 comes from + the PAYLOAD_TABLE resource; both are checked before anything is unpacked. uninstall.exe (this program + without payload) runs its removal from a copy in a fresh %TEMP% folder, so it never deletes the image it + runs from (04 §6.7). + + Interactive runs show the wizard (setup_ui.c, the page flow in setup_flow.c). /quiet shows nothing, + /passive shows the progress view only. /test-pages is a hidden switch for the tests: it builds the pages and + installs nothing (tests/setup). */ +#include "setup_int.h" + +#include +#include +#include + +#include "sd_l10n/sd_l10n.h" +#include "setup_elevate.h" +#include "setup_flow.h" +#include "setup_trust.h" +#include "setup_payload_sig.h" +#include "setup_ui.h" + +#ifndef SD_VERSION_STRING_W +#define SD_VERSION_STRING_W L"0.0.0" +#endif + +#ifndef LOAD_LIBRARY_SEARCH_SYSTEM32 +#define LOAD_LIBRARY_SEARCH_SYSTEM32 0x00000800 +#endif + +#define SD_TITLE L"ShellDrive Setup" + +typedef BOOL (WINAPI *sd_SetDefaultDllDirectories_t)(DWORD); +typedef BOOL (WINAPI *sd_SetDllDirectoryW_t)(LPCWSTR); + +static void restrict_dll_search(void) +{ + sd_SetDefaultDllDirectories_t set_dirs = + (sd_SetDefaultDllDirectories_t)sd_setup_proc(L"kernel32.dll", "SetDefaultDllDirectories"); + sd_SetDllDirectoryW_t clear_dir = (sd_SetDllDirectoryW_t)sd_setup_proc(L"kernel32.dll", "SetDllDirectoryW"); + + if (set_dirs != NULL) + set_dirs(LOAD_LIBRARY_SEARCH_SYSTEM32); + else if (clear_dir != NULL) + clear_dir(L""); /* XP, and Vista without KB2533623: drop the current directory (04 §6.4) */ +} + +/* Shows the final message of an operation. Errors are always shown. A success is shown only when show_success + is set: the installer's success is the finish page, a passive or quiet run shows nothing. */ +static void show_result(const sd_setup_result *res, int show_success) +{ + wchar_t text[1024]; + wchar_t logline[MAX_PATH + 128]; + wchar_t full[MAX_PATH + 1280]; + + if (res->msg == 0) + return; + if (res->exit_code == SD_SETUP_EXIT_OK && !show_success) + return; + if (sd_setup_format_message(res, res->msg, text, SD_SETUP_COUNTOF(text)) < 0) + return; + StringCchCopyW(full, SD_SETUP_COUNTOF(full), text); + if (res->exit_code != SD_SETUP_EXIT_OK && res->log_path[0] != L'\0' && + sd_setup_format_message(res, SD_MSG_INSTALLER_LOG_SAVED, logline, SD_SETUP_COUNTOF(logline)) >= 0) { + StringCchCatW(full, SD_SETUP_COUNTOF(full), L"\r\n"); + StringCchCatW(full, SD_SETUP_COUNTOF(full), logline); + } + sd_setup_ui_message(NULL, SD_TITLE, full, res->exit_code != SD_SETUP_EXIT_OK); +} + +/* Progress callback: formats the stage with its folder and shows it in the window. */ +static void progress_cb(void *ctx, sd_msg_id stage, const wchar_t *folder, unsigned percent) +{ + sd_setup_ui *ui = (sd_setup_ui *)ctx; + wchar_t text[512]; + sd_setup_result tmp; + + memset(&tmp, 0, sizeof(tmp)); + if (folder != NULL) + StringCchCopyW(tmp.folder, SD_SETUP_COUNTOF(tmp.folder), folder); + if (stage != 0 && sd_setup_format_message(&tmp, stage, text, SD_SETUP_COUNTOF(text)) >= 0) + sd_setup_ui_status(ui, text, percent); + else + sd_setup_ui_status(ui, L"", percent); +} + +static void fail_result(sd_setup_result *res, int exit_code, sd_msg_id msg) +{ + memset(res, 0, sizeof(*res)); + res->exit_code = exit_code; + res->msg = msg; +} + +/* Copies this image into a fresh folder under %TEMP% that only the user and SYSTEM may write, then starts + the copy with the uninstall switches. flag is "/quiet", "/passive" or "" (interactive). The copy waits for + this process to exit (04 §6.7). */ +static int relaunch_uninstall_from_temp(const wchar_t *install_dir, const wchar_t *self, const wchar_t *flag) +{ + wchar_t temp[MAX_PATH]; + wchar_t hex[40]; + wchar_t leaf[64]; + wchar_t copy_dir[MAX_PATH]; + wchar_t copy_exe[MAX_PATH]; + wchar_t sid[128]; + wchar_t sddl[256]; + wchar_t cmd[MAX_PATH * 3]; + PSECURITY_DESCRIPTOR sd = NULL; + SECURITY_ATTRIBUTES sa; + STARTUPINFOW si; + PROCESS_INFORMATION pi; + int rc = 1; + + if (sd_setup_temp_dir(temp, SD_SETUP_COUNTOF(temp)) != 0 || sd_setup_random_hex(hex, SD_SETUP_COUNTOF(hex)) != 0) + return 1; + StringCchPrintfW(leaf, SD_SETUP_COUNTOF(leaf), L"ShellDrive-uninstall-%s", hex); + if (sd_setup_path_join(copy_dir, MAX_PATH, temp, leaf) != 0) + return 1; + if (sd_setup_current_sid(sid, SD_SETUP_COUNTOF(sid)) != 0) + return 1; + StringCchPrintfW(sddl, SD_SETUP_COUNTOF(sddl), L"D:P(A;OICI;FA;;;SY)(A;OICI;FA;;;%s)", sid); + if (!ConvertStringSecurityDescriptorToSecurityDescriptorW(sddl, SDDL_REVISION_1, &sd, NULL)) + return 1; + memset(&sa, 0, sizeof(sa)); + sa.nLength = sizeof(sa); + sa.lpSecurityDescriptor = sd; + sa.bInheritHandle = FALSE; + if (!CreateDirectoryW(copy_dir, &sa)) { + LocalFree(sd); + return 1; + } + LocalFree(sd); + + if (sd_setup_path_join(copy_exe, MAX_PATH, copy_dir, L"uninstall.exe") != 0 || !CopyFileW(self, copy_exe, FALSE)) + return 1; + + StringCchPrintfW(cmd, SD_SETUP_COUNTOF(cmd), + L"\"%s\" /uninstall-from-temp /installdir=\"%s\" /waitpid=%lu %s", copy_exe, install_dir, + (unsigned long)GetCurrentProcessId(), flag); + memset(&si, 0, sizeof(si)); + si.cb = sizeof(si); + memset(&pi, 0, sizeof(pi)); + if (CreateProcessW(copy_exe, cmd, NULL, NULL, FALSE, 0, NULL, copy_dir, &si, &pi)) { + CloseHandle(pi.hThread); + CloseHandle(pi.hProcess); + rc = 0; + } + return rc; +} + +/* True when the running image sits in the install folder recorded for the account. */ +static int running_from_install_dir(const wchar_t *self, const wchar_t *install_dir) +{ + wchar_t here[MAX_PATH]; + wchar_t dir[MAX_PATH]; + wchar_t *slash; + size_t n; + + StringCchCopyW(here, SD_SETUP_COUNTOF(here), self); + slash = wcsrchr(here, L'\\'); + if (slash == NULL) + return 0; + *slash = L'\0'; + StringCchCopyW(dir, SD_SETUP_COUNTOF(dir), install_dir); + n = wcslen(dir); + while (n > 0 && dir[n - 1] == L'\\') + dir[--n] = L'\0'; + return _wcsicmp(here, dir) == 0; +} + +/* Starts the installed ShellDrive (finish page, "Launch ShellDrive"). A failure is shown to the user. */ +static void launch_shelldrive(const wchar_t *folder, HWND owner) +{ + wchar_t exe[MAX_PATH]; + wchar_t cmd[MAX_PATH + 4]; + wchar_t text[512]; + sd_setup_result tmp; + STARTUPINFOW si; + PROCESS_INFORMATION pi; + + if (folder == NULL || sd_setup_path_join(exe, MAX_PATH, folder, L"shelldrive-ui.exe") != 0) + return; + StringCchPrintfW(cmd, SD_SETUP_COUNTOF(cmd), L"\"%s\"", exe); + memset(&si, 0, sizeof(si)); + si.cb = sizeof(si); + memset(&pi, 0, sizeof(pi)); + if (CreateProcessW(exe, cmd, NULL, NULL, FALSE, 0, NULL, folder, &si, &pi)) { + CloseHandle(pi.hThread); + CloseHandle(pi.hProcess); + return; + } + memset(&tmp, 0, sizeof(tmp)); + if (sd_setup_format_message(&tmp, SD_MSG_INSTALLER_LAUNCH_FAILED, text, SD_SETUP_COUNTOF(text)) >= 0) + sd_setup_ui_message(owner, SD_TITLE, text, 1); +} + +static int run_machine_elevated(sd_setup_ui *ui, const sd_setup_args *args, const wchar_t *self, sd_setup_result *res, + sd_msg_id done); + +/* Starts the UI that an all-users install put in place, as the logged-on user (04 §6.4): this is the unelevated parent, never + the elevated stage. A failure is shown. */ +static void launch_installed_ui(void) +{ + sd_setup_elev_ops ops; + wchar_t text[512]; + sd_setup_result tmp; + + sd_setup_elev_real_ops(&ops); + if (sd_setup_elev_launch_installed(&ops) == 0) + return; + memset(&tmp, 0, sizeof(tmp)); + if (sd_setup_format_message(&tmp, SD_MSG_INSTALLER_LAUNCH_FAILED, text, SD_SETUP_COUNTOF(text)) >= 0) + sd_setup_ui_message(NULL, SD_TITLE, text, 1); +} + +/* True when this uninstall is the per-machine one (04 §6.7): the ShellDrive MSI is registered in the machine registry, and + either no folder was named or the named folder is the machine install folder (or the running image sits there). dir gets + the machine folder when none was named. Read-only. */ +static int is_machine_uninstall(const wchar_t *self, wchar_t *dir, size_t cch, wchar_t *code, size_t ccode) +{ + wchar_t mdir[MAX_PATH]; + int known; + + if (!sd_setup_machine_product(code, ccode)) + return 0; + known = sd_setup_machine_install_dir(mdir, SD_SETUP_COUNTOF(mdir)) == 0; + if (dir[0] == L'\0') { + /* Only the machine install is known here: the elevated stage finds the product itself. */ + if (known) + StringCchCopyW(dir, cch, mdir); + return 1; + } + return known && (_wcsicmp(mdir, dir) == 0 || running_from_install_dir(self, mdir)); +} + +static int run_uninstall(HINSTANCE inst, const sd_setup_args *args, const wchar_t *self, sd_setup_result *res) +{ + sd_setup_uninstall_opts uo; + sd_setup_ui *ui = NULL; + sd_flow_input flow; + sd_setup_defaults defs; + sd_setup_choices choice; + wchar_t dir[MAX_PATH]; + wchar_t code[64]; + int interactive = !args->quiet && !args->passive; + int machine; + int rc; + + memset(&uo, 0, sizeof(uo)); + memset(&flow, 0, sizeof(flow)); + memset(&defs, 0, sizeof(defs)); + memset(&choice, 0, sizeof(choice)); + dir[0] = L'\0'; + if (args->install_dir[0] != L'\0') { + StringCchCopyW(dir, SD_SETUP_COUNTOF(dir), args->install_dir); + } else { + sd_setup_registered_install_dir(dir, SD_SETUP_COUNTOF(dir)); + } + machine = is_machine_uninstall(self, dir, SD_SETUP_COUNTOF(dir), code, SD_SETUP_COUNTOF(code)); + + if (!args->quiet && sd_setup_ui_create(inst, SD_TITLE, &ui) != 0) + ui = NULL; + + /* The removal confirmation comes first, once: the copy in %TEMP% started below does not ask again. */ + if (interactive && !args->uninstall_relaunched) { + if (ui == NULL) { + fail_result(res, SD_SETUP_EXIT_FATAL, SD_MSG_INSTALLER_ERROR_GENERIC); + return SD_SETUP_EXIT_FATAL; + } + flow.uninstall = 1; + defs.mode = SD_SETUP_MODE_UNINSTALL; + defs.uninstall_dir = dir; + defs.dokany_plan = SD_SETUP_DOKANY_SKIP_PLAN; + if (!sd_setup_ui_run_pages(ui, &flow, &defs, &choice)) { + sd_setup_ui_destroy(ui); + fail_result(res, SD_SETUP_EXIT_CANCEL, 0); + return SD_SETUP_EXIT_CANCEL; + } + } + + if (machine) { + /* Per-machine: the elevated stages run msiexec /x for the registered product (04 §6.7). The image is not copied to + %TEMP%: Windows Installer removes the installed files itself. */ + if (ui != NULL) + sd_setup_ui_begin_progress(ui); + rc = run_machine_elevated(ui, args, self, res, SD_MSG_INSTALLER_DONE_UNINSTALL); + sd_setup_ui_destroy(ui); + return rc; + } + + if (dir[0] != L'\0' && running_from_install_dir(self, dir) && !args->uninstall_relaunched) { + const wchar_t *flag = args->quiet ? L"/quiet" : (args->passive ? L"/passive" : L""); + if (relaunch_uninstall_from_temp(dir, self, flag) == 0) { + /* The copy in %TEMP% now does the work and reports on its own window. */ + sd_setup_ui_destroy(ui); + memset(res, 0, sizeof(*res)); + res->exit_code = SD_SETUP_EXIT_OK; + return SD_SETUP_EXIT_OK; + } + /* Could not copy: continue from here; the folder removal fails only for the running image. */ + } + + if (args->wait_pid != 0) { + HANDLE parent = OpenProcess(SYNCHRONIZE, FALSE, (DWORD)args->wait_pid); + if (parent != NULL) { + WaitForSingleObject(parent, 30000); + CloseHandle(parent); + } + } + + uo.install_dir = dir[0] != L'\0' ? dir : NULL; + uo.force = args->force; + uo.log_path = args->log_path[0] != L'\0' ? args->log_path : NULL; + if (ui != NULL) { + sd_setup_ui_begin_progress(ui); + uo.progress = progress_cb; + uo.progress_ctx = ui; + } + rc = sd_setup_uninstall(&uo, res); + sd_setup_ui_destroy(ui); + return rc; +} + +/* The payload of the flavour this host runs, and its SHA-256 from the PAYLOAD_TABLE resource (04 §6.1). Returns 0, or 1 + with *msg set. Nothing is unpacked here. A payload without a valid release signature gets its own message (TM-R38). */ +static int load_payload(HINSTANCE inst, sd_setup_flavour flavour, sd_setup_payload *payload, unsigned char expected[32], + sd_msg_id *msg) +{ + HRSRC hres; + HGLOBAL hglob; + HRSRC htab; + HGLOBAL htglob; + + hres = FindResourceW(inst, sd_setup_payload_resource(flavour), RT_RCDATA); + hglob = hres != NULL ? LoadResource(inst, hres) : NULL; + if (hglob == NULL) { + *msg = SD_MSG_INSTALLER_ERROR_PAYLOAD_MISSING; + return 1; + } + payload->data = (const unsigned char *)LockResource(hglob); + payload->size = SizeofResource(inst, hres); + + /* Without the table there is no verified hash: the stub refuses (TM-R38 until the signature exists). */ + htab = FindResourceW(inst, L"PAYLOAD_TABLE", RT_RCDATA); + htglob = htab != NULL ? LoadResource(inst, htab) : NULL; + if (htglob == NULL || + sd_setup_payload_table_lookup((const char *)LockResource(htglob), SizeofResource(inst, htab), + sd_setup_flavour_name(flavour), expected) != 0) { + *msg = SD_MSG_INSTALLER_ERROR_PAYLOAD_DAMAGED; + return 1; + } + /* TM-R38: the table must carry a valid release signature (PAYLOAD_SIG) in every path that extracts, the elevated + stage 2 included; fails closed. */ + if (sd_setup_payload_sig_check(inst, (const unsigned char *)LockResource(htglob), SizeofResource(inst, htab)) != + SD_SETUP_PSIG_OK) { + *msg = SD_MSG_INSTALLER_ERROR_PAYLOAD_SIGNATURE; + return 1; + } + return 0; +} + +/* The flavour of this host, the same choice in every stage (the forwarded switches carry /flavour= when it was forced). */ +static int host_flavour(sd_setup_flavour *flavour, sd_msg_id *reason, const sd_setup_args *args) +{ + sd_setup_host host; + sd_setup_detect_host(&host); + return sd_setup_select_flavour(&host, args->flavour_forced ? args->flavour : SD_SETUP_FL_NONE, flavour, reason); +} + +/* /extract= (04 §6.5): the MSI of the payload is written to the folder, for administrators who deploy it themselves. + Nothing is installed, and no elevation is needed: the folder is the user's own. The copy is ShellDrive.msi. */ +static int run_extract(const sd_setup_payload *payload, const sd_setup_args *args, sd_setup_result *res) +{ + wchar_t temp[MAX_PATH]; + wchar_t hex[40]; + wchar_t leaf[64]; + wchar_t work[MAX_PATH]; + wchar_t msi[MAX_PATH]; + wchar_t dst[MAX_PATH]; + sd_setup_result cab; + DWORD attrs; + unsigned files = 0; + + memset(&cab, 0, sizeof(cab)); + if (sd_setup_temp_dir(temp, SD_SETUP_COUNTOF(temp)) != 0 || sd_setup_random_hex(hex, SD_SETUP_COUNTOF(hex)) != 0) { + fail_result(res, SD_SETUP_EXIT_FATAL, SD_MSG_INSTALLER_ERROR_WRITE); + return SD_SETUP_EXIT_FATAL; + } + StringCchPrintfW(leaf, SD_SETUP_COUNTOF(leaf), L"ShellDrive-extract-%s", hex); + if (sd_setup_path_join(work, MAX_PATH, temp, leaf) != 0 || sd_setup_mkdirs(work) != 0) { + fail_result(res, SD_SETUP_EXIT_FATAL, SD_MSG_INSTALLER_ERROR_WRITE); + return SD_SETUP_EXIT_FATAL; + } + if (sd_setup_cab_extract(payload, work, NULL, &cab, &files) != 0) { + sd_setup_remove_tree(work, NULL); + fail_result(res, SD_SETUP_EXIT_FATAL, cab.msg != 0 ? cab.msg : SD_MSG_INSTALLER_ERROR_PAYLOAD_EXTRACT); + return SD_SETUP_EXIT_FATAL; + } + if (sd_setup_path_join(msi, MAX_PATH, work, L"ShellDrive.msi") != 0) { + sd_setup_remove_tree(work, NULL); + fail_result(res, SD_SETUP_EXIT_FATAL, SD_MSG_INSTALLER_ERROR_WRITE); + return SD_SETUP_EXIT_FATAL; + } + attrs = GetFileAttributesW(msi); + if (attrs == INVALID_FILE_ATTRIBUTES || (attrs & FILE_ATTRIBUTE_DIRECTORY) != 0) { + sd_setup_remove_tree(work, NULL); + fail_result(res, SD_SETUP_EXIT_FATAL, SD_MSG_INSTALLER_ERROR_MSI_MISSING); + return SD_SETUP_EXIT_FATAL; + } + if (sd_setup_mkdirs(args->extract_dir) != 0 || + sd_setup_path_join(dst, MAX_PATH, args->extract_dir, L"ShellDrive.msi") != 0 || !CopyFileW(msi, dst, FALSE)) { + sd_setup_remove_tree(work, NULL); + fail_result(res, SD_SETUP_EXIT_FATAL, SD_MSG_INSTALLER_ERROR_WRITE); + return SD_SETUP_EXIT_FATAL; + } + sd_setup_remove_tree(work, NULL); + memset(res, 0, sizeof(*res)); + res->exit_code = SD_SETUP_EXIT_OK; + StringCchCopyW(res->folder, SD_SETUP_COUNTOF(res->folder), args->extract_dir); + return SD_SETUP_EXIT_OK; +} + +/* Progress of the parent while the elevated stages run: the value is a percentage, or SD_SETUP_ELEV_TICK (pump only). */ +static void elev_progress(void *ctx, unsigned value) +{ + sd_setup_ui *ui = (sd_setup_ui *)ctx; + if (value == SD_SETUP_ELEV_TICK) { + if (ui != NULL) + sd_setup_ui_pump(ui); + return; + } + sd_setup_ui_status(ui, L"", value); +} + +/* Per-machine install or uninstall (04 §6.3, 6.4, 6.7): the unelevated parent starts the two stages and relays their progress + to ui (which has begun its progress view, or is NULL for /quiet). The UAC prompt is the consent step. No path is passed to + the elevated stages. done is the success message; a reboot-pending uninstall also gets it. */ +static int run_machine_elevated(sd_setup_ui *ui, const sd_setup_args *args, const wchar_t *self, sd_setup_result *res, + sd_msg_id done) +{ + sd_setup_elev_ops ops; + wchar_t base[MAX_PATH]; + int rc; + + if (sd_setup_common_appdata(base, SD_SETUP_COUNTOF(base)) != 0) { + fail_result(res, SD_SETUP_EXIT_FATAL, SD_MSG_INSTALLER_ERROR_ELEVATION); + return SD_SETUP_EXIT_FATAL; + } + sd_setup_elev_real_ops(&ops); + rc = sd_setup_elev_parent_ex(&ops, base, self, args, res, elev_progress, ui); + if (res->msg == 0 && (rc == SD_SETUP_EXIT_OK || (rc == SD_SETUP_EXIT_REBOOT && done == SD_MSG_INSTALLER_DONE_UNINSTALL))) + res->msg = done; + return rc; +} + +/* The install: per-user, portable or all users (04 §6.3). The payload is checked first, so a damaged or unsigned setup + stops before any page. Interactive runs show the wizard; the all-users choice runs the elevated stages. */ +static int run_install(HINSTANCE inst, const sd_setup_args *args, const wchar_t *self, sd_setup_result *res) +{ + sd_setup_host host; + sd_setup_flavour flavour = SD_SETUP_FL_NONE; + sd_msg_id reason = 0; + sd_setup_payload payload; + sd_setup_install_opts io; + unsigned char expected[32]; + sd_setup_ui *ui = NULL; + sd_flow_input flow; + sd_setup_defaults defs; + sd_setup_choices choice; + sd_setup_mode mode; + const wchar_t *portable_dir; + int interactive = !args->quiet && !args->passive; + int rc; + + sd_setup_detect_host(&host); + if (sd_setup_select_flavour(&host, args->flavour_forced ? args->flavour : SD_SETUP_FL_NONE, &flavour, &reason) != 0) { + fail_result(res, SD_SETUP_EXIT_FATAL, reason); + if (args->flavour_forced && sd_setup_flavour_name(args->flavour) != NULL) + StringCchCopyW(res->text, SD_SETUP_COUNTOF(res->text), sd_setup_flavour_name(args->flavour)); + return SD_SETUP_EXIT_FATAL; + } + /* The flavour is checked before any UAC prompt and before any page (TM-R38). */ + if (load_payload(inst, flavour, &payload, expected, &reason) != 0) { + fail_result(res, SD_SETUP_EXIT_FATAL, reason); + if (reason == SD_MSG_INSTALLER_ERROR_PAYLOAD_MISSING) + StringCchCopyW(res->text, SD_SETUP_COUNTOF(res->text), sd_setup_flavour_name(flavour)); + return SD_SETUP_EXIT_FATAL; + } + if (args->extract_dir[0] != L'\0') + return run_extract(&payload, args, res); + + if (!args->quiet && sd_setup_ui_create(inst, SD_TITLE, &ui) != 0) + ui = NULL; + + mode = args->mode == SD_SETUP_MODE_PORTABLE ? SD_SETUP_MODE_PORTABLE + : args->mode == SD_SETUP_MODE_MACHINE ? SD_SETUP_MODE_MACHINE + : SD_SETUP_MODE_USER; + portable_dir = args->portable_dir; + if (interactive) { + if (ui == NULL) { + fail_result(res, SD_SETUP_EXIT_FATAL, SD_MSG_INSTALLER_ERROR_GENERIC); + return SD_SETUP_EXIT_FATAL; + } + memset(&flow, 0, sizeof(flow)); + memset(&defs, 0, sizeof(defs)); + memset(&choice, 0, sizeof(choice)); + flow.mode_given = args->mode != SD_SETUP_MODE_AUTO; + flow.dest_given = args->portable_dir[0] != L'\0'; + defs.mode = args->mode; + defs.portable_dir = args->portable_dir; + defs.uninstall_dir = L""; + defs.dokany_plan = sd_setup_dokany_decide(SD_SETUP_MODE_MACHINE, args->dokany_set, args->dokany, + host.os_major, host.os_minor); + if (!sd_setup_ui_run_pages(ui, &flow, &defs, &choice)) { + sd_setup_ui_destroy(ui); + fail_result(res, SD_SETUP_EXIT_CANCEL, 0); + return SD_SETUP_EXIT_CANCEL; + } + mode = choice.mode; + portable_dir = choice.portable_dir; + } + if (ui != NULL) + sd_setup_ui_begin_progress(ui); + + if (mode == SD_SETUP_MODE_MACHINE) { + rc = run_machine_elevated(ui, args, self, res, SD_MSG_INSTALLER_DONE_INSTALL); + } else { + memset(&io, 0, sizeof(io)); + io.mode = mode; + io.install_dir = args->install_dir[0] != L'\0' ? args->install_dir : NULL; + io.portable_dir = portable_dir; + io.version = SD_VERSION_STRING_W; + io.flavour = flavour; + io.payload = &payload; + io.expected_sha256 = expected; + io.autostart = args->autostart; + io.force = args->force; + io.update = args->update; + io.log_path = args->log_path[0] != L'\0' ? args->log_path : NULL; + io.progress = progress_cb; + io.progress_ctx = ui; + rc = sd_setup_install(&io, res); + } + if (rc == SD_SETUP_EXIT_OK && interactive && ui != NULL) { + /* The finish page carries the success text and the Launch checkbox. An all-users install starts the UI the MSI + installed, from this unelevated parent (the folder comes from the machine registry); see launch_installed_ui. */ + wchar_t text[1024]; + int launch = 0; + if (sd_setup_format_message(res, res->msg, text, SD_SETUP_COUNTOF(text)) < 0) + text[0] = L'\0'; + if (sd_setup_ui_finish(ui, text, &launch) && launch) { + if (mode == SD_SETUP_MODE_MACHINE) + launch_installed_ui(); + else + launch_shelldrive(res->folder, NULL); + } + } + sd_setup_ui_destroy(ui); + return rc; +} + +/* The elevated stages (--elevated-stage1 and --elevated-child, 04 §6.4). Stage 1 needs no payload; stage 2 checks the + payload table of its own image before it extracts anything. */ +static int run_elevated_stage(HINSTANCE inst, const sd_setup_args *args, const wchar_t *self) +{ + sd_setup_flavour flavour = SD_SETUP_FL_NONE; + sd_msg_id reason = 0; + sd_setup_payload payload; + unsigned char expected[32]; + sd_setup_elev_ops ops; + wchar_t base[MAX_PATH]; + DWORD rc; + + if (sd_setup_common_appdata(base, SD_SETUP_COUNTOF(base)) != 0) + return SD_SETUP_EXIT_FATAL; + sd_setup_elev_real_ops(&ops); + if (args->elevate_stage == 1) { + DWORD child = 0; + rc = sd_setup_elev_stage1(&ops, base, self, args, &child); + return rc == 0 ? SD_SETUP_EXIT_OK : SD_SETUP_EXIT_FATAL; + } + /* A per-machine uninstall needs no payload: its stage 2 runs msiexec /x for the registered product (04 §6.7). */ + if (args->mode == SD_SETUP_MODE_UNINSTALL) { + rc = sd_setup_elev_stage2_uninstall(&ops, base, self, args); + return (int)rc; + } + if (host_flavour(&flavour, &reason, args) != 0 || load_payload(inst, flavour, &payload, expected, &reason) != 0) + return SD_SETUP_EXIT_FATAL; + rc = sd_setup_elev_stage2(&ops, base, self, args, &payload, expected); + return (int)rc; +} + +/* /test-pages: builds every page of the flow for the command line, writes the page names to the log and returns. + Nothing is installed, no registry value is written and no file is touched. */ +static int run_test_pages(HINSTANCE inst, const sd_setup_args *args) +{ + sd_setup_ui *ui = NULL; + sd_setup_host host; + sd_flow_input flow; + sd_setup_defaults defs; + unsigned built = 0; + int ok; + + if (args->log_path[0] != L'\0') + sd_setup_log_open(args->log_path); + if (sd_setup_ui_create(inst, SD_TITLE, &ui) != 0) { + sd_setup_log_close(); + return SD_SETUP_EXIT_FATAL; + } + sd_setup_detect_host(&host); + memset(&flow, 0, sizeof(flow)); + memset(&defs, 0, sizeof(defs)); + flow.passive = args->passive; + flow.uninstall = args->mode == SD_SETUP_MODE_UNINSTALL; + flow.mode_given = args->mode != SD_SETUP_MODE_AUTO; + flow.dest_given = args->portable_dir[0] != L'\0'; + defs.mode = args->mode; + defs.portable_dir = args->portable_dir; + defs.uninstall_dir = args->install_dir; + defs.dokany_plan = sd_setup_dokany_decide(SD_SETUP_MODE_MACHINE, args->dokany_set, args->dokany, host.os_major, + host.os_minor); + ok = sd_setup_ui_test_pages(ui, &flow, &defs, &built); + sd_setup_ui_destroy(ui); + sd_setup_log_close(); + return ok && built > 0 ? SD_SETUP_EXIT_OK : SD_SETUP_EXIT_FATAL; +} + +int APIENTRY wWinMain(HINSTANCE inst, HINSTANCE prev, LPWSTR cmdline, int show) +{ + sd_setup_args args; + sd_setup_result res; + wchar_t **argv = NULL; + int argc = 0; + const wchar_t *bad = NULL; + wchar_t self[MAX_PATH]; + int rc; + + (void)prev; + (void)cmdline; + (void)show; + restrict_dll_search(); + memset(&res, 0, sizeof(res)); + + argv = CommandLineToArgvW(GetCommandLineW(), &argc); + if (argv == NULL) + return SD_SETUP_EXIT_FATAL; + + rc = sd_setup_parse_args(argc, (const wchar_t *const *)argv, &args, &bad); + sd_l10n_init(&sd_l10n_catalog_installer, args.lang[0] != L'\0' ? args.lang : NULL); + if (rc != 0) { + fail_result(&res, rc, SD_MSG_INSTALLER_ERROR_ARGS); + StringCchCopyW(res.text, SD_SETUP_COUNTOF(res.text), bad != NULL ? bad : L""); + show_result(&res, 1); + LocalFree(argv); + return rc; + } + + if (args.help) { + fail_result(&res, SD_SETUP_EXIT_OK, SD_MSG_INSTALLER_HELP); + show_result(&res, 1); + LocalFree(argv); + return SD_SETUP_EXIT_OK; + } + + if (args.test_pages) { + rc = run_test_pages(inst, &args); + LocalFree(argv); + return rc; + } + + GetModuleFileNameW(NULL, self, SD_SETUP_COUNTOF(self)); + + /* The two elevated stages (04 §6.4): no window, no UI, nothing printed. */ + if (args.elevate_stage != 0) { + rc = run_elevated_stage(inst, &args, self); + LocalFree(argv); + return rc; + } + + if (args.mode == SD_SETUP_MODE_UNINSTALL) + rc = run_uninstall(inst, &args, self, &res); + else + rc = run_install(inst, &args, self, &res); + + /* A removal says so in a message box when it was shown interactively; an install ends on its finish page. */ + show_result(&res, args.mode == SD_SETUP_MODE_UNINSTALL && !args.quiet && !args.passive); + LocalFree(argv); + return rc; +} diff --git a/apps/shelldrive-setup/setup_msg.c b/apps/shelldrive-setup/setup_msg.c new file mode 100644 index 0000000..8532c90 --- /dev/null +++ b/apps/shelldrive-setup/setup_msg.c @@ -0,0 +1,43 @@ +/* shelldrive-setup: user-visible text. Every message comes from the installer catalog (l10n/installer, + 03 §9, 04 §8.6); this file only supplies the placeholder values of a result. */ +#include "setup_int.h" + +#include "sd_l10n/sd_l10n.h" + +static void set_str(sd_arg *a, const char *name, const wchar_t *s) +{ + a->name = name; + a->type = SD_ARG_STR; + a->v.s = s; +} + +static void set_int(sd_arg *a, const char *name, long long i) +{ + a->name = name; + a->type = SD_ARG_INT; + a->v.i = i; +} + +int sd_setup_format_message(const sd_setup_result *r, sd_msg_id id, wchar_t *buf, size_t cch) +{ + /* One argument per placeholder name used by the catalog. Arguments a message does not use are ignored. */ + sd_arg args[9]; + const wchar_t *text = r != NULL ? r->text : L""; + const wchar_t *folder = r != NULL ? r->folder : L""; + const wchar_t *version = r != NULL ? r->version : L""; + const wchar_t *log_path = r != NULL ? r->log_path : L""; + long long code = r != NULL ? (long long)r->code : 0; + long long count = r != NULL ? (long long)r->count : 0; + + set_str(&args[0], "flavour", text); + set_str(&args[1], "name", text); + set_str(&args[2], "option", text); + set_str(&args[3], "file", text); + set_str(&args[4], "path", id == SD_MSG_INSTALLER_LOG_SAVED ? log_path : text); + set_str(&args[5], "folder", folder); + set_str(&args[6], "version", version); + set_int(&args[7], "code", code); + set_int(&args[8], "count", count); + + return sd_l10n_format(buf, cch, id, args, SD_SETUP_COUNTOF(args)); +} diff --git a/apps/shelldrive-setup/setup_payload_sig.c b/apps/shelldrive-setup/setup_payload_sig.c new file mode 100644 index 0000000..ee596ab --- /dev/null +++ b/apps/shelldrive-setup/setup_payload_sig.c @@ -0,0 +1,267 @@ +/* Payload table signature (docs/design/04 §6.1, §9.3; TM-R38, T-71). See setup_payload_sig.h. + * + * Fail closed: any malformed input, unknown key, wrong channel, revoked or out-of-date key, or a signature that + * does not verify returns an error, and the stub refuses to unpack. Only sd_crypto (sd_ed25519_verify, + * sd_hash) is used, which the stub may link (no CNG, TM-R33). */ +#include +#include + +#include "sd_crypto/sd_crypto.h" +#include "setup_payload_sig.h" + +#ifndef SD_SETUP_BUILD_CHANNEL +#define SD_SETUP_BUILD_CHANNEL "stable" +#endif + +/* The keyring compiled into the stub: the keys of the build's keyset (installer/keys/keyset.txt, §9.3). The file + in the repository holds only a revoked placeholder, so no payload verifies until the release pipeline + generates it. */ +static const sd_setup_psig_key k_builtin_keys[] = { +#include "setup_keyring.inc" +}; + +static const char k_header_magic[] = "shelldrive-payload-table 1\n"; +static const char k_header_product[] = "product " SD_SETUP_PSIG_PRODUCT "\n"; +static const char k_sig_prefix[] = "sig ed25519 "; + +/* ---- base64 (RFC 4648, canonical, padded) ---- */ + +static int b64_val(char c) +{ + if (c >= 'A' && c <= 'Z') return c - 'A'; + if (c >= 'a' && c <= 'z') return c - 'a' + 26; + if (c >= '0' && c <= '9') return c - '0' + 52; + if (c == '+') return 62; + if (c == '/') return 63; + return -1; +} + +int sd_setup_psig_b64_decode(const char *s, size_t len, uint8_t *out, size_t cap) +{ + size_t i, o = 0; + if (s == NULL || out == NULL || len == 0 || (len % 4) != 0) + return -1; + for (i = 0; i < len; i += 4) { + int v[4], pad = 0; + size_t k; + for (k = 0; k < 4; k++) { + char c = s[i + k]; + if (c == '=') { + if (i + 4 != len || k < 2) + return -1; + pad++; + v[k] = 0; + } else { + if (pad) + return -1; + v[k] = b64_val(c); + if (v[k] < 0) + return -1; + } + } + if (pad == 1 && (v[2] & 0x3) != 0) + return -1; + if (pad == 2 && (v[1] & 0xF) != 0) + return -1; + { + uint8_t b[3]; + size_t n = 3 - (size_t)pad; + b[0] = (uint8_t)((v[0] << 2) | (v[1] >> 4)); + b[1] = (uint8_t)(((v[1] & 0xF) << 4) | (v[2] >> 2)); + b[2] = (uint8_t)(((v[2] & 0x3) << 6) | v[3]); + if (o + n > cap) + return -1; + memcpy(out + o, b, n); + o += n; + } + } + return (int)o; +} + +static int hex_val(char c) +{ + if (c >= '0' && c <= '9') return c - '0'; + if (c >= 'a' && c <= 'f') return c - 'a' + 10; + return -1; +} + +/* ---- table syntax ---- */ + +/* Printable ASCII and LF only, LF-terminated. */ +static int table_text_ok(const uint8_t *t, size_t n) +{ + size_t i; + if (n == 0 || t[n - 1] != '\n') + return 0; + for (i = 0; i < n; i++) { + if (t[i] != '\n' && (t[i] < 0x21 || t[i] > 0x7E) && t[i] != ' ') + return 0; + } + return 1; +} + +/* Returns the line length (without LF) starting at *pos and advances *pos past the LF; 0 at the end. */ +static size_t next_line(const uint8_t *t, size_t n, size_t *pos, const uint8_t **line) +{ + size_t s = *pos, e = s; + if (s >= n) + return 0; + while (e < n && t[e] != '\n') + e++; + *line = t + s; + *pos = (e < n) ? e + 1 : e; + return e - s; +} + +int sd_setup_psig_table_channel(const uint8_t *table, size_t tlen, char *out, size_t cap) +{ + size_t pos = 0, len; + const uint8_t *line; + int line_no = 0; + if (table == NULL || out == NULL || cap == 0) + return 1; + while ((len = next_line(table, tlen, &pos, &line)) != 0) { + line_no++; + if (line_no == 2) { + /* product line: checked by the verifier, skipped here */ + continue; + } + if (line_no == 3) { + if (len < 9 || memcmp(line, "channel ", 8) != 0 || len - 8 >= cap) + return 1; + memcpy(out, line + 8, len - 8); + out[len - 8] = '\0'; + return 0; + } + } + return 1; +} + +/* Header: magic line, product line, channel line. Returns SD_SETUP_PSIG_OK when the header is well formed and its + channel is the expected one, the error of the first failed rule otherwise. */ +static sd_setup_psig_res check_header(const uint8_t *t, size_t n, const char *expected, char channel[8]) +{ + size_t pos = 0, len; + const uint8_t *line; + if (n < sizeof(k_header_magic) - 1 || memcmp(t, k_header_magic, sizeof(k_header_magic) - 1) != 0) + return SD_SETUP_PSIG_E_FORMAT; + (void)next_line(t, n, &pos, &line); /* magic, checked above */ + len = next_line(t, n, &pos, &line); /* product */ + if (len != sizeof(k_header_product) - 2 || memcmp(line, k_header_product, len) != 0) + return SD_SETUP_PSIG_E_PRODUCT; + len = next_line(t, n, &pos, &line); /* channel */ + if (len < 9 || len > 8 + 7 || memcmp(line, "channel ", 8) != 0) + return SD_SETUP_PSIG_E_FORMAT; + if (!((len == 14 && memcmp(line + 8, "stable", 6) == 0) || (len == 12 && memcmp(line + 8, "beta", 4) == 0))) + return SD_SETUP_PSIG_E_FORMAT; + memcpy(channel, line + 8, len - 8); + channel[len - 8] = '\0'; + if (expected == NULL || strcmp(channel, expected) != 0) + return SD_SETUP_PSIG_E_CHANNEL; + return SD_SETUP_PSIG_OK; +} + +/* sig line: "sig ed25519 <16 lowercase hex> <88 base64>" followed by LF, exactly. */ +static sd_setup_psig_res parse_sig(const uint8_t *s, size_t n, uint8_t kid[8], uint8_t sig[64]) +{ + char b64[88]; + size_t i; + if (n > SD_SETUP_PSIG_SIG_MAX) + return SD_SETUP_PSIG_E_TOO_LARGE; + /* 12 + 16 + 1 + 88 + LF */ + if (n != 118 || s[117] != '\n') + return SD_SETUP_PSIG_E_FORMAT; + if (memcmp(s, k_sig_prefix, sizeof(k_sig_prefix) - 1) != 0 || s[28] != ' ') + return SD_SETUP_PSIG_E_FORMAT; + for (i = 0; i < 8; i++) { + int hi = hex_val((char)s[12 + 2 * i]), lo = hex_val((char)s[13 + 2 * i]); + if (hi < 0 || lo < 0) + return SD_SETUP_PSIG_E_FORMAT; + kid[i] = (uint8_t)((hi << 4) | lo); + } + for (i = 0; i < 88; i++) { + if (s[29 + i] == '\n' || s[29 + i] == ' ') + return SD_SETUP_PSIG_E_FORMAT; + b64[i] = (char)s[29 + i]; + } + if (sd_setup_psig_b64_decode(b64, 88, sig, 64) != 64) + return SD_SETUP_PSIG_E_FORMAT; + return SD_SETUP_PSIG_OK; +} + +sd_setup_psig_res sd_setup_psig_verify(const uint8_t *table, size_t tlen, const uint8_t *sig, size_t slen, + const sd_setup_psig_key *keys, size_t nkeys, + const char *channel_expected, int64_t now) +{ + uint8_t kid[8], sigb[64], pub_hash[32]; + char channel[8]; + sd_setup_psig_res r; + size_t i, match = (size_t)-1; + /* domain || table, on the stack: the table is bounded by SD_SETUP_PSIG_TABLE_MAX. */ + uint8_t msg[sizeof(SD_SETUP_PSIG_DOMAIN) + SD_SETUP_PSIG_TABLE_MAX]; + size_t dl = sizeof(SD_SETUP_PSIG_DOMAIN) - 1; + + if (keys == NULL || nkeys == 0) + return SD_SETUP_PSIG_E_NO_KEYS; + if (table == NULL || sig == NULL || channel_expected == NULL) + return SD_SETUP_PSIG_E_FORMAT; + if (tlen == 0 || tlen > SD_SETUP_PSIG_TABLE_MAX || slen > SD_SETUP_PSIG_SIG_MAX) + return SD_SETUP_PSIG_E_TOO_LARGE; + if (!table_text_ok(table, tlen)) + return SD_SETUP_PSIG_E_FORMAT; + if ((r = check_header(table, tlen, channel_expected, channel)) != SD_SETUP_PSIG_OK) + return r; + if ((r = parse_sig(sig, slen, kid, sigb)) != SD_SETUP_PSIG_OK) + return r; + + for (i = 0; i < nkeys; i++) { + sd_hash(SD_HASH_SHA256, keys[i].pub, 32, pub_hash); + if (memcmp(pub_hash, kid, 8) == 0) { + match = i; + break; + } + } + if (match == (size_t)-1) + return SD_SETUP_PSIG_E_UNKNOWN_KEY; + if (keys[match].revoked || now < keys[match].valid_from || now > keys[match].valid_to) + return SD_SETUP_PSIG_E_KEY_INVALID; + if (strcmp(keys[match].channel, channel) != 0) + return SD_SETUP_PSIG_E_CHANNEL; + + memcpy(msg, SD_SETUP_PSIG_DOMAIN, dl); + memcpy(msg + dl, table, tlen); + if (sd_ed25519_verify(sigb, keys[match].pub, msg, dl + tlen) != SD_OK) + return SD_SETUP_PSIG_E_SIGNATURE; + return SD_SETUP_PSIG_OK; +} + +int64_t sd_setup_psig_now(void) +{ + FILETIME ft; + ULARGE_INTEGER u; + GetSystemTimeAsFileTime(&ft); + u.LowPart = ft.dwLowDateTime; + u.HighPart = ft.dwHighDateTime; + /* 100 ns intervals since 1601-01-01 -> unix seconds */ + return (int64_t)(u.QuadPart / 10000000ull) - 11644473600ll; +} + +sd_setup_psig_res sd_setup_payload_sig_check(HINSTANCE inst, const uint8_t *table, size_t tlen) +{ + HRSRC hr; + HGLOBAL hg; + const uint8_t *p; + DWORD n; + + hr = FindResourceW(inst, L"PAYLOAD_SIG", RT_RCDATA); + hg = hr != NULL ? LoadResource(inst, hr) : NULL; + if (hg == NULL || table == NULL) + return SD_SETUP_PSIG_E_RESOURCE; + p = (const uint8_t *)LockResource(hg); + n = SizeofResource(inst, hr); + if (p == NULL || n == 0) + return SD_SETUP_PSIG_E_RESOURCE; + return sd_setup_psig_verify(table, tlen, p, n, k_builtin_keys, + sizeof(k_builtin_keys) / sizeof(k_builtin_keys[0]), + SD_SETUP_BUILD_CHANNEL, sd_setup_psig_now()); +} diff --git a/apps/shelldrive-setup/setup_payload_sig.h b/apps/shelldrive-setup/setup_payload_sig.h new file mode 100644 index 0000000..81e0404 --- /dev/null +++ b/apps/shelldrive-setup/setup_payload_sig.h @@ -0,0 +1,73 @@ +/* Payload table signature (docs/design/04 §6.1, §9.3; TM-R38, T-71). + * + * The setup stub refuses to unpack a payload unless its PAYLOAD_TABLE is signed (PAYLOAD_SIG) by a release key + * that the keyring compiled into the stub authorizes for the build's channel, inside its validity dates and not + * revoked. Verification is fail-closed: every error is a refusal. + * + * Signed bytes: SD_SETUP_PSIG_DOMAIN || . The table is LF-terminated printable ASCII: + * + * shelldrive-payload-table 1 + * product com.librestatic.shelldrive + * channel stable + * x86-ia32 (one line per flavour; the stub reads its own, see setup_args.c) + * + * PAYLOAD_SIG is one line: "sig ed25519 " + LF. + * Key ID = first 8 bytes of SHA-256(public key) (the same rule as sd_update). + * + * The functions here are pure (no clock, no resources) so tests run them with generated keys. The resource + * wrapper sd_setup_payload_sig_check is the single call site in the stub (setup_main.c). + * + * Shared with tools/sd-release and tools/sd-payload through the sd_payload_sig library (no engine code). */ +#ifndef SD_SETUP_PAYLOAD_SIG_H +#define SD_SETUP_PAYLOAD_SIG_H + +#include +#include +#include + +#define SD_SETUP_PSIG_DOMAIN "com.librestatic.shelldrive payload table v1\n" +#define SD_SETUP_PSIG_PRODUCT "com.librestatic.shelldrive" +#define SD_SETUP_PSIG_TABLE_MAX 4096u +#define SD_SETUP_PSIG_SIG_MAX 256u + +typedef struct sd_setup_psig_key { + uint8_t pub[32]; + char channel[8]; /* "stable" | "beta" */ + int64_t valid_from; /* unix seconds, inclusive */ + int64_t valid_to; /* unix seconds, inclusive */ + int revoked; +} sd_setup_psig_key; + +typedef enum sd_setup_psig_res { + SD_SETUP_PSIG_OK = 0, + SD_SETUP_PSIG_E_NO_KEYS, /* empty keyring: nothing can verify */ + SD_SETUP_PSIG_E_TOO_LARGE, /* table or signature line over its limit */ + SD_SETUP_PSIG_E_FORMAT, /* table header or sig line is malformed */ + SD_SETUP_PSIG_E_PRODUCT, /* product is not com.librestatic.shelldrive */ + SD_SETUP_PSIG_E_CHANNEL, /* table channel differs from the build channel, or the key is for another channel */ + SD_SETUP_PSIG_E_UNKNOWN_KEY, /* key id is not in the keyring */ + SD_SETUP_PSIG_E_KEY_INVALID, /* revoked, or now outside the validity dates */ + SD_SETUP_PSIG_E_SIGNATURE, /* the signature does not verify over domain || table */ + SD_SETUP_PSIG_E_RESOURCE /* PAYLOAD_SIG or PAYLOAD_TABLE missing or unreadable (resource wrapper only) */ +} sd_setup_psig_res; + +/* Verifies the table against the signature line. channel_expected: the build's channel. now: unix seconds. + Returns SD_SETUP_PSIG_OK only when every check passes. */ +sd_setup_psig_res sd_setup_psig_verify(const uint8_t *table, size_t tlen, const uint8_t *sig, size_t slen, + const sd_setup_psig_key *keys, size_t nkeys, + const char *channel_expected, int64_t now); + +/* Parses the "channel" header line of a table into out (NUL-terminated). 0 on success. */ +int sd_setup_psig_table_channel(const uint8_t *table, size_t tlen, char *out, size_t cap); + +/* Base64 (RFC 4648, padded, canonical) decode for the sig line. Returns the length or -1. */ +int sd_setup_psig_b64_decode(const char *s, size_t len, uint8_t *out, size_t cap); + +/* Current time as unix seconds (GetSystemTimeAsFileTime). */ +int64_t sd_setup_psig_now(void); + +/* Resource wrapper for the stub: loads PAYLOAD_SIG from inst, checks the PAYLOAD_TABLE bytes against the keyring + compiled into the stub (setup_payload_sig.c, setup_keyring.inc) for SD_SETUP_BUILD_CHANNEL. 0 = verified. */ +sd_setup_psig_res sd_setup_payload_sig_check(HINSTANCE inst, const uint8_t *table, size_t tlen); + +#endif /* SD_SETUP_PAYLOAD_SIG_H */ diff --git a/apps/shelldrive-setup/setup_reg.c b/apps/shelldrive-setup/setup_reg.c new file mode 100644 index 0000000..37316ea --- /dev/null +++ b/apps/shelldrive-setup/setup_reg.c @@ -0,0 +1,330 @@ +/* shelldrive-setup: account registration (docs/design/04 §6.6, §6.7). The rows come from + packaging/registration.txt through the generated table sd_setup_regtable (one source for the per-user + setup and the MSI registry tables). Placeholders are expanded here: [INSTALLDIR], [VERSION], [FLAVOUR], + [SIZEKB]. Removal deletes our values and only the keys this product owns and that end up empty. */ +#include "setup_int.h" + +#include +#include + +/* Expands the [PLACEHOLDER] tokens of a row into out. Unknown tokens stay as written. */ +static int expand(const wchar_t *src, wchar_t *out, size_t cch, const sd_setup_regctx *c) +{ + size_t o = 0; + const wchar_t *p = src; + + while (*p != L'\0') { + const wchar_t *close; + size_t tlen; + const wchar_t *rep = NULL; + wchar_t num[24]; + + if (*p == L'[' && (close = wcschr(p, L']')) != NULL) { + tlen = (size_t)(close - p) + 1; + if (tlen == 12 && wcsncmp(p, L"[INSTALLDIR]", 12) == 0) + rep = c->install_dir; + else if (tlen == 14 && wcsncmp(p, L"[INSTALLDIR32]", 14) == 0) + rep = c->install_dir32; + else if (tlen == 9 && wcsncmp(p, L"[VERSION]", 9) == 0) + rep = c->version; + else if (tlen == 9 && wcsncmp(p, L"[FLAVOUR]", 9) == 0) + rep = c->flavour; + else if (tlen == 8 && wcsncmp(p, L"[SIZEKB]", 8) == 0) { + unsigned long long kb = (c->install_bytes + 1023) / 1024; + StringCchPrintfW(num, SD_SETUP_COUNTOF(num), L"%I64u", kb); + rep = num; + } + if (rep != NULL) { + size_t rl = wcslen(rep); + if (o + rl >= cch) + return 1; + memcpy(out + o, rep, rl * sizeof(wchar_t)); + o += rl; + p += tlen; + continue; + } + } + if (o + 1 >= cch) + return 1; + out[o++] = *p++; + } + out[o] = L'\0'; + return 0; +} + +/* True when needle occurs in hay. Local: the CRT wcsstr is not on the IA32 ISA allowlist (04 §2.4). */ +static int contains_text(const wchar_t *hay, const wchar_t *needle) +{ + size_t nl = wcslen(needle); + for (; *hay != L'\0'; hay++) { + if (wcsncmp(hay, needle, nl) == 0) + return 1; + } + return 0; +} + +/* Our two shell extension CLSIDs (packaging/registration.txt). Keys under them are ours, whatever their names. */ +static const wchar_t k_clsid_context_menu[] = L"{C2A44993-862B-42AB-B40B-254EFB1F5C43}"; +static const wchar_t k_clsid_this_pc[] = L"{5E1C7D02-3B8F-4A9E-9C61-7D24A0F3B5E8}"; + +/* True when the last component of subkey is ours: it names ShellDrive or the executable. */ +static int leaf_is_ours(const wchar_t *subkey) +{ + const wchar_t *leaf = wcsrchr(subkey, L'\\'); + leaf = leaf != NULL ? leaf + 1 : subkey; + return contains_text(leaf, L"ShellDrive") || contains_text(leaf, L"shelldrive-ui.exe"); +} + +/* Keys we may delete when empty. Only the rows of packaging/registration.txt are candidates, so no parent that no row + names is ever deleted. Of those, only keys we own: the last component names ShellDrive or shelldrive-ui.exe, or the + key sits under one of our CLSIDs (the CLSID key and its InprocServer32). Shared keys (Run, Approved, shellex, + ContextMenuHandlers, the Windows This PC CLSID) never qualify. */ +static int row_is_product_key(const wchar_t *subkey) +{ + return leaf_is_ours(subkey) || contains_text(subkey, k_clsid_context_menu) || contains_text(subkey, k_clsid_this_pc); +} + +/* ------------------------------------------------------------------ machine (MSI) registry */ + +#ifndef KEY_WOW64_64KEY +#define KEY_WOW64_64KEY 0x0100 +#endif +#ifndef KEY_WOW64_32KEY +#define KEY_WOW64_32KEY 0x0200 +#endif + +static const wchar_t k_arp_root[] = L"Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall"; +static const wchar_t k_machine_state[] = L"Software\\LibreStatic\\ShellDrive"; + +/* Reads a REG_SZ value of an open key. 1 when it is not there or not a string. */ +static int key_sz(HKEY hk, const wchar_t *name, wchar_t *out, DWORD cbytes) +{ + DWORD type = 0; + DWORD size = cbytes; + if (out == NULL || cbytes < sizeof(wchar_t)) + return 1; + if (RegQueryValueExW(hk, name, NULL, &type, (BYTE *)out, &size) != ERROR_SUCCESS || type != REG_SZ) + return 1; + out[cbytes / sizeof(wchar_t) - 1] = L'\0'; + return 0; +} + +/* 1 when name has the shape of a product code: braces, 36 characters, hyphens in the GUID places. */ +static int product_name_ok(const wchar_t *name) +{ + size_t i; + if (wcslen(name) != 38 || name[0] != L'{' || name[37] != L'}') + return 0; + for (i = 1; i < 37; i++) { + int dash = (i == 9 || i == 14 || i == 19 || i == 24); + wchar_t c = name[i]; + if (dash) { + if (c != L'-') + return 0; + } else if (!((c >= L'0' && c <= L'9') || (c >= L'a' && c <= L'f') || (c >= L'A' && c <= L'F'))) { + return 0; + } + } + return 1; +} + +/* 1 when the open Uninstall entry is the ShellDrive MSI product of LibreStatic (Windows Installer sets WindowsInstaller). */ +static int entry_is_shelldrive_msi(HKEY hk) +{ + wchar_t text[128]; + DWORD type = 0; + DWORD size = sizeof(DWORD); + DWORD installer = 0; + + if (key_sz(hk, L"DisplayName", text, sizeof(text)) != 0 || _wcsicmp(text, L"ShellDrive") != 0) + return 0; + if (key_sz(hk, L"Publisher", text, sizeof(text)) != 0 || _wcsicmp(text, L"LibreStatic") != 0) + return 0; + if (RegQueryValueExW(hk, L"WindowsInstaller", NULL, &type, (BYTE *)&installer, &size) != ERROR_SUCCESS || + type != REG_DWORD || installer != 1) + return 0; + return 1; +} + +int sd_setup_machine_product(wchar_t *code, size_t cch) +{ + static const REGSAM views[2] = { KEY_WOW64_64KEY, KEY_WOW64_32KEY }; + size_t v; + + if (code == NULL || cch < 39) + return 0; + for (v = 0; v < 2; v++) { + HKEY root = NULL; + DWORD index; + + if (RegOpenKeyExW(HKEY_LOCAL_MACHINE, k_arp_root, 0, KEY_READ | views[v], &root) != ERROR_SUCCESS) + continue; + for (index = 0;; index++) { + wchar_t name[128]; + DWORD len = SD_SETUP_COUNTOF(name); + HKEY entry = NULL; + int found; + + if (RegEnumKeyExW(root, index, name, &len, NULL, NULL, NULL, NULL) != ERROR_SUCCESS) + break; + if (!product_name_ok(name) || RegOpenKeyExW(root, name, 0, KEY_READ, &entry) != ERROR_SUCCESS) + continue; + found = entry_is_shelldrive_msi(entry); + RegCloseKey(entry); + if (found) { + StringCchCopyW(code, cch, name); + RegCloseKey(root); + return 1; + } + } + RegCloseKey(root); + } + return 0; +} + +int sd_setup_machine_install_dir(wchar_t *out, size_t cch) +{ + static const REGSAM views[2] = { KEY_WOW64_64KEY, KEY_WOW64_32KEY }; + size_t v; + + if (out == NULL || cch == 0) + return 1; + for (v = 0; v < 2; v++) { + HKEY hk = NULL; + wchar_t raw[MAX_PATH * 2]; + size_t n; + + if (RegOpenKeyExW(HKEY_LOCAL_MACHINE, k_machine_state, 0, KEY_READ | views[v], &hk) != ERROR_SUCCESS) + continue; + if (key_sz(hk, L"InstallDir", raw, sizeof(raw)) == 0) { + RegCloseKey(hk); + if (FAILED(StringCchCopyW(out, cch, raw))) + return 1; + n = wcslen(out); + while (n > 0 && out[n - 1] == L'\\') + out[--n] = L'\0'; + return n > 0 ? 0 : 1; + } + RegCloseKey(hk); + } + return 1; +} + +/* Access mask of the view a row belongs to (04 §6.2, 03 §8.5). 0 = the setup process's own view. KEY_WOW64_* are + ignored on 32-bit Windows. The SHELL32 rows always name the 32-bit view: they are written only with an x86 copy. */ +static REGSAM row_view_access(const sd_setup_regrow *row, const sd_setup_regctx *c) +{ + if (row->view == SD_SETUP_REGVIEW_SHELL) + return c->shell_access; + if (row->view == SD_SETUP_REGVIEW_SHELL32) + return KEY_WOW64_32KEY; + return 0; +} + +/* Deletes an empty key in the given view. RegDeleteKeyExW is resolved at run time (XP x64 and later, 04 §2.4). */ +typedef LONG (WINAPI *sd_RegDeleteKeyExW_t)(HKEY, const wchar_t *, REGSAM, DWORD); + +static LONG delete_key_in_view(HKEY root, const wchar_t *subkey, REGSAM view) +{ + sd_RegDeleteKeyExW_t del = NULL; + if (view == 0) + return RegDeleteKeyW(root, subkey); + del = (sd_RegDeleteKeyExW_t)sd_setup_proc(L"advapi32.dll", "RegDeleteKeyExW"); + if (del == NULL) + return RegDeleteKeyW(root, subkey); + return del(root, subkey, view, 0); +} + +int sd_setup_reg_apply(const sd_setup_regctx *c) +{ + size_t i; + wchar_t data[MAX_PATH * 4]; + + for (i = 0; i < sd_setup_regtable_count; i++) { + const sd_setup_regrow *row = &sd_setup_regtable[i]; + HKEY hk = NULL; + LONG rc; + DWORD disp = 0; + + if (row->autostart && !c->autostart) + continue; + /* No x86 copy installed (or the views are shared): the 32-bit view is not written (see sd_setup_shell32_wanted). */ + if (row->view == SD_SETUP_REGVIEW_SHELL32 && c->install_dir32 == NULL) + continue; + if (expand(row->data, data, SD_SETUP_COUNTOF(data), c) != 0) + return SD_SETUP_EXIT_FATAL; + + rc = RegCreateKeyExW(c->root, row->subkey, 0, NULL, REG_OPTION_NON_VOLATILE, KEY_SET_VALUE | row_view_access(row, c), + NULL, &hk, &disp); + if (rc != ERROR_SUCCESS) { + sd_setup_log_error("reg.create", row->subkey, (DWORD)rc); + return SD_SETUP_EXIT_FATAL; + } + if (row->is_dword) { + DWORD v = (DWORD)_wtol(data); + rc = RegSetValueExW(hk, row->name, 0, REG_DWORD, (const BYTE *)&v, sizeof(v)); + } else { + rc = RegSetValueExW(hk, row->name, 0, REG_SZ, (const BYTE *)data, + (DWORD)((wcslen(data) + 1) * sizeof(wchar_t))); + } + RegCloseKey(hk); + if (rc != ERROR_SUCCESS) { + sd_setup_log_error("reg.set", row->subkey, (DWORD)rc); + return SD_SETUP_EXIT_FATAL; + } + } + return 0; +} + +int sd_setup_reg_remove(const sd_setup_regctx *c) +{ + size_t i; + + /* The SHELL32 values are removed whatever the install: the 32-bit view is the one of the x86 copy, and removing an + absent value is harmless. */ + for (i = 0; i < sd_setup_regtable_count; i++) { + const sd_setup_regrow *row = &sd_setup_regtable[i]; + HKEY hk = NULL; + REGSAM view = row->view == SD_SETUP_REGVIEW_SHELL32 ? KEY_WOW64_32KEY : row_view_access(row, c); + + /* An empty name is the key's default value, deleted the same way (so an emptied key can go). */ + if (RegOpenKeyExW(c->root, row->subkey, 0, KEY_SET_VALUE | view, &hk) == ERROR_SUCCESS) { + RegDeleteValueW(hk, row->name != NULL ? row->name : L""); + RegCloseKey(hk); + } + } + + /* Keys that belong to ShellDrive and are now empty. Walk the table backwards so that children go first. */ + for (i = sd_setup_regtable_count; i > 0; i--) { + const sd_setup_regrow *row = &sd_setup_regtable[i - 1]; + HKEY hk = NULL; + DWORD subkeys = 0, values = 0; + REGSAM view = row->view == SD_SETUP_REGVIEW_SHELL32 ? KEY_WOW64_32KEY : row_view_access(row, c); + + if (!row_is_product_key(row->subkey)) + continue; + if (RegOpenKeyExW(c->root, row->subkey, 0, KEY_READ | view, &hk) != ERROR_SUCCESS) + continue; + if (RegQueryInfoKeyW(hk, NULL, NULL, NULL, &subkeys, NULL, NULL, &values, NULL, NULL, NULL, NULL) == + ERROR_SUCCESS && subkeys == 0 && values == 0) { + RegCloseKey(hk); + hk = NULL; + delete_key_in_view(c->root, row->subkey, view); + } + if (hk != NULL) + RegCloseKey(hk); + } + return 0; +} + +/* The views of the x64 and x86 copies (04 §6.2): x64 Windows XP and Server 2003 keep HKCU\Software\Classes per view; + later systems share one tree between the views, so the x86 copy is not registered there. */ +int sd_setup_views_split(const sd_setup_host *h) +{ + return h != NULL && h->native_machine == IMAGE_FILE_MACHINE_AMD64 && h->os_major == 5 && h->os_minor == 2; +} + +int sd_setup_shell32_wanted(sd_setup_flavour f, const sd_setup_host *h) +{ + return (f == SD_SETUP_FL_X64_XP || f == SD_SETUP_FL_X64_MODERN) && sd_setup_views_split(h); +} diff --git a/apps/shelldrive-setup/setup_sha256.c b/apps/shelldrive-setup/setup_sha256.c new file mode 100644 index 0000000..69440fb --- /dev/null +++ b/apps/shelldrive-setup/setup_sha256.c @@ -0,0 +1,130 @@ +/* SHA-256 (FIPS 180-4) for the payload check (04 §6.1). Our own code: the stub cannot link sd_crypto, + whose CNG-based objects import bcrypt.dll (TM-R33). */ +/* Self-contained (windows.h only): tools/sd-payload includes this file too. */ +#include + +#include + +void sd_setup_sha256(const void *data, size_t n, unsigned char out[32]); +int sd_setup_sha256_hex(const unsigned char sha[32], char out[65]); + +typedef struct sd_sha256_ctx { + DWORD h[8]; + unsigned char buf[64]; + size_t buflen; + unsigned long long total; +} sd_sha256_ctx; + +static const DWORD k_sha256[64] = { + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, + 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, + 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, + 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, + 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, + 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, + 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, + 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2 +}; + +#define ROR(x, n) (((x) >> (n)) | ((x) << (32 - (n)))) + +static void sha256_block(sd_sha256_ctx *c, const unsigned char *p) +{ + DWORD w[64]; + DWORD a, b, cc, d, e, f, g, h; + int i; + + for (i = 0; i < 16; i++) { + w[i] = ((DWORD)p[4 * i] << 24) | ((DWORD)p[4 * i + 1] << 16) | ((DWORD)p[4 * i + 2] << 8) | + (DWORD)p[4 * i + 3]; + } + for (i = 16; i < 64; i++) { + DWORD s0 = ROR(w[i - 15], 7) ^ ROR(w[i - 15], 18) ^ (w[i - 15] >> 3); + DWORD s1 = ROR(w[i - 2], 17) ^ ROR(w[i - 2], 19) ^ (w[i - 2] >> 10); + w[i] = w[i - 16] + s0 + w[i - 7] + s1; + } + + a = c->h[0]; b = c->h[1]; cc = c->h[2]; d = c->h[3]; + e = c->h[4]; f = c->h[5]; g = c->h[6]; h = c->h[7]; + + for (i = 0; i < 64; i++) { + DWORD S1 = ROR(e, 6) ^ ROR(e, 11) ^ ROR(e, 25); + DWORD ch = (e & f) ^ (~e & g); + DWORD t1 = h + S1 + ch + k_sha256[i] + w[i]; + DWORD S0 = ROR(a, 2) ^ ROR(a, 13) ^ ROR(a, 22); + DWORD maj = (a & b) ^ (a & cc) ^ (b & cc); + DWORD t2 = S0 + maj; + h = g; g = f; f = e; e = d + t1; + d = cc; cc = b; b = a; a = t1 + t2; + } + + c->h[0] += a; c->h[1] += b; c->h[2] += cc; c->h[3] += d; + c->h[4] += e; c->h[5] += f; c->h[6] += g; c->h[7] += h; +} + +static void sha256_init(sd_sha256_ctx *c) +{ + c->h[0] = 0x6a09e667; c->h[1] = 0xbb67ae85; c->h[2] = 0x3c6ef372; c->h[3] = 0xa54ff53a; + c->h[4] = 0x510e527f; c->h[5] = 0x9b05688c; c->h[6] = 0x1f83d9ab; c->h[7] = 0x5be0cd19; + c->buflen = 0; + c->total = 0; +} + +static void sha256_update(sd_sha256_ctx *c, const unsigned char *p, size_t n) +{ + c->total += n; + while (n > 0) { + size_t take = 64 - c->buflen; + if (take > n) + take = n; + memcpy(c->buf + c->buflen, p, take); + c->buflen += take; + p += take; + n -= take; + if (c->buflen == 64) { + sha256_block(c, c->buf); + c->buflen = 0; + } + } +} + +static void sha256_final(sd_sha256_ctx *c, unsigned char out[32]) +{ + unsigned long long bits = c->total * 8; + unsigned char pad[72]; + size_t padlen = (c->buflen < 56) ? (56 - c->buflen) : (120 - c->buflen); + int i; + + memset(pad, 0, sizeof(pad)); + pad[0] = 0x80; + for (i = 0; i < 8; i++) + pad[padlen + i] = (unsigned char)(bits >> (56 - 8 * i)); + sha256_update(c, pad, padlen + 8); + + for (i = 0; i < 8; i++) { + out[4 * i] = (unsigned char)(c->h[i] >> 24); + out[4 * i + 1] = (unsigned char)(c->h[i] >> 16); + out[4 * i + 2] = (unsigned char)(c->h[i] >> 8); + out[4 * i + 3] = (unsigned char)c->h[i]; + } +} + +void sd_setup_sha256(const void *data, size_t n, unsigned char out[32]) +{ + sd_sha256_ctx c; + sha256_init(&c); + sha256_update(&c, (const unsigned char *)data, n); + sha256_final(&c, out); +} + +int sd_setup_sha256_hex(const unsigned char sha[32], char out[65]) +{ + static const char digits[] = "0123456789abcdef"; + int i; + for (i = 0; i < 32; i++) { + out[2 * i] = digits[sha[i] >> 4]; + out[2 * i + 1] = digits[sha[i] & 15]; + } + out[64] = '\0'; + return 0; +} diff --git a/apps/shelldrive-setup/setup_shortcut.c b/apps/shelldrive-setup/setup_shortcut.c new file mode 100644 index 0000000..0dd42da --- /dev/null +++ b/apps/shelldrive-setup/setup_shortcut.c @@ -0,0 +1,48 @@ +/* shelldrive-setup: Start menu shortcut through IShellLinkW (docs/design/04 §6.7 and the account setup). + The CLSID and IIDs are spelled out here, so no uuid.lib is linked (the stub links nothing outside + KnownDLLs, 04 §2.4). */ +#include "setup_int.h" + +#include + +static const GUID sd_clsid_shelllink = { 0x00021401, 0, 0, { 0xC0, 0, 0, 0, 0, 0, 0, 0x46 } }; +static const GUID sd_iid_shelllinkw = { 0x000214F9, 0, 0, { 0xC0, 0, 0, 0, 0, 0, 0, 0x46 } }; +static const GUID sd_iid_persistfile = { 0x0000010B, 0, 0, { 0xC0, 0, 0, 0, 0, 0, 0, 0x46 } }; + +int sd_setup_shortcut_create(const wchar_t *lnk, const wchar_t *target, const wchar_t *workdir, + const wchar_t *description, const wchar_t *icon) +{ + HRESULT hr; + IShellLinkW *link = NULL; + IPersistFile *file = NULL; + int rc = 1; + BOOL initialized; + + hr = CoInitializeEx(NULL, COINIT_APARTMENTTHREADED); + initialized = (hr == S_OK || hr == S_FALSE); + if (FAILED(hr) && hr != RPC_E_CHANGED_MODE) + return 1; + + hr = CoCreateInstance(&sd_clsid_shelllink, NULL, CLSCTX_INPROC_SERVER, &sd_iid_shelllinkw, (void **)&link); + if (SUCCEEDED(hr) && link != NULL) { + link->lpVtbl->SetPath(link, target); + if (workdir != NULL) + link->lpVtbl->SetWorkingDirectory(link, workdir); + if (description != NULL) + link->lpVtbl->SetDescription(link, description); + if (icon != NULL) + link->lpVtbl->SetIconLocation(link, icon, 0); + hr = link->lpVtbl->QueryInterface(link, &sd_iid_persistfile, (void **)&file); + if (SUCCEEDED(hr) && file != NULL) { + hr = file->lpVtbl->Save(file, lnk, TRUE); + if (SUCCEEDED(hr)) + rc = 0; + file->lpVtbl->Release(file); + } + link->lpVtbl->Release(link); + } + + if (initialized) + CoUninitialize(); + return rc; +} diff --git a/apps/shelldrive-setup/setup_trust.c b/apps/shelldrive-setup/setup_trust.c new file mode 100644 index 0000000..e9b695a --- /dev/null +++ b/apps/shelldrive-setup/setup_trust.c @@ -0,0 +1,105 @@ +/* shelldrive-setup: %ProgramData% trust and the staging folder (docs/design/04 §6.4, TM-R34, T-63). + + The check is svc_repair_data (apps/shelldrive-host/svc_repair.c, shared source): every check runs on a handle opened + with FILE_FLAG_OPEN_REPARSE_POINT, a link or a foreign-owned folder is renamed aside (never followed, never read), and + a fresh folder is created with the 03 §3.2 DACL in the same call. This file adds the setup's log line, the common + AppData lookup and the staging folder, which is created with its DACL in one call so no other user can write into it + between the two steps. */ +#include "setup_int.h" + +#include +#include +#include +#include + +#include "setup_trust.h" +#include "svc_repair.h" + +#ifndef CSIDL_COMMON_APPDATA +#define CSIDL_COMMON_APPDATA 0x0023 +#endif + +/* Protected DACL of a stage folder: SYSTEM and Administrators only (04 §6.4, stage 1 step c). */ +static const wchar_t *const k_stage_sddl = L"D:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)"; + +DWORD sd_setup_secure_programdata_with(const svc_repair_ops *ops, const wchar_t *base, sd_setup_trust_result *out) +{ + svc_repair_result rr; + DWORD err; + + if (ops == NULL || base == NULL || out == NULL) + return ERROR_INVALID_PARAMETER; + memset(out, 0, sizeof(*out)); + memset(&rr, 0, sizeof(rr)); + err = svc_repair_data(ops, base, &rr); + out->refused = rr.refused; + out->moved = rr.moved; + out->folders = rr.folders; + StringCchCopyW(out->path, SD_SETUP_COUNTOF(out->path), rr.path); + if (rr.moved) + sd_setup_log("setup.programdata_untrusted", rr.path); + return err; +} + +DWORD sd_setup_secure_programdata(const wchar_t *base, sd_setup_trust_result *out) +{ + svc_repair_ops ops; + + svc_repair_real_ops(&ops); + return sd_setup_secure_programdata_with(&ops, base, out); +} + +DWORD sd_setup_common_appdata(wchar_t *out, size_t cch) +{ + if (out == NULL || cch < MAX_PATH) + return ERROR_INVALID_PARAMETER; + return sd_setup_special_folder(CSIDL_COMMON_APPDATA, out, cch) == 0 ? 0 : ERROR_PATH_NOT_FOUND; +} + +DWORD sd_setup_create_stage_dir(const wchar_t *setup_dir, const wchar_t *extra_ace, wchar_t *out, size_t cch) +{ + wchar_t sddl[256]; + wchar_t hex[40]; + wchar_t path[MAX_PATH]; + PSECURITY_DESCRIPTOR sd = NULL; + SECURITY_ATTRIBUTES sa; + unsigned attempt; + DWORD err = ERROR_INTERNAL_ERROR; + + if (setup_dir == NULL || out == NULL || cch < MAX_PATH) + return ERROR_INVALID_PARAMETER; + if (extra_ace != NULL) + StringCchPrintfW(sddl, SD_SETUP_COUNTOF(sddl), L"%s%s", k_stage_sddl, extra_ace); + else + StringCchCopyW(sddl, SD_SETUP_COUNTOF(sddl), k_stage_sddl); + if (sd_setup_mkdirs(setup_dir) != 0) + return ERROR_PATH_NOT_FOUND; + if (!ConvertStringSecurityDescriptorToSecurityDescriptorW(sddl, SDDL_REVISION_1, &sd, NULL)) + return GetLastError(); + + for (attempt = 0; attempt < 8; attempt++) { + if (sd_setup_random_hex(hex, SD_SETUP_COUNTOF(hex)) != 0) { + err = ERROR_GEN_FAILURE; + break; + } + if (sd_setup_path_join(path, MAX_PATH, setup_dir, hex) != 0) { + err = ERROR_BUFFER_OVERFLOW; + break; + } + memset(&sa, 0, sizeof(sa)); + sa.nLength = sizeof(sa); + sa.lpSecurityDescriptor = sd; + sa.bInheritHandle = FALSE; + if (CreateDirectoryW(path, &sa)) { + StringCchCopyW(out, cch, path); + err = 0; + break; + } + err = GetLastError(); + if (err != ERROR_ALREADY_EXISTS) + break; + err = ERROR_ALREADY_EXISTS; + } + LocalFree(sd); + return err; +} diff --git a/apps/shelldrive-setup/setup_trust.h b/apps/shelldrive-setup/setup_trust.h new file mode 100644 index 0000000..debb571 --- /dev/null +++ b/apps/shelldrive-setup/setup_trust.h @@ -0,0 +1,39 @@ +/* shelldrive-setup: %ProgramData% trust before elevated use (docs/design/04 §6.4, TM-R34, T-63) and the + per-run staging folder of the elevated stages (04 §6.4 stage 1, step c). + + The owner, link and DACL checks are the service's own (apps/shelldrive-host/svc_repair.c, compiled into + sd_setup_core from its shared source), so setup and `shelldrive service repair-data` cannot disagree. */ +#ifndef SD_SETUP_TRUST_H +#define SD_SETUP_TRUST_H + +#include + +#include "setup_core.h" + +struct svc_repair_ops; + +typedef struct sd_setup_trust_result { + int refused; /* the base is a link: nothing was changed */ + int moved; /* an untrusted folder was moved aside; path is its new name */ + wchar_t path[MAX_PATH]; + DWORD folders; /* folders and stores checked, set or created */ +} sd_setup_trust_result; + +/* Secures \LibreStatic and \LibreStatic\ShellDrive (base is %ProgramData%, CSIDL_COMMON_APPDATA) before + anything is written there. The shipping owner rule (SYSTEM and Administrators) applies. Returns 0 or a Win32 + error; a moved folder is logged as setup.programdata_untrusted. The caller checks elevation first. */ +DWORD sd_setup_secure_programdata(const wchar_t *base, sd_setup_trust_result *out); + +/* The same check with the seam given (tests): ops is a svc_repair_ops, extra_ace is appended to every DACL. */ +DWORD sd_setup_secure_programdata_with(const struct svc_repair_ops *ops, const wchar_t *base, + sd_setup_trust_result *out); + +/* %ProgramData% (CSIDL_COMMON_APPDATA) with no trailing backslash. Returns 0 or a Win32 error. */ +DWORD sd_setup_common_appdata(wchar_t *out, size_t cch); + +/* Creates a new folder \ with a protected DACL (SYSTEM and Administrators full control, + plus extra_ace when not NULL) in the same CreateDirectoryW call. setup_dir is created when missing. The name is new + every time: an existing name is never reused (up to 8 attempts). out gets the full path. Returns 0 or a Win32 error. */ +DWORD sd_setup_create_stage_dir(const wchar_t *setup_dir, const wchar_t *extra_ace, wchar_t *out, size_t cch); + +#endif /* SD_SETUP_TRUST_H */ diff --git a/apps/shelldrive-setup/setup_ui.c b/apps/shelldrive-setup/setup_ui.c new file mode 100644 index 0000000..c04cd85 --- /dev/null +++ b/apps/shelldrive-setup/setup_ui.c @@ -0,0 +1,682 @@ +/* shelldrive-setup: the wizard window (see setup_ui.h). Only user32, gdi32 and shell32 (KnownDLLs, already imported by + the stub). No common controls, so the stub needs no comctl32 import (04 §2.4). The folder dialog and the item + list helpers are resolved at run time (SHBrowseForFolderW, SHGetPathFromIDListW, CoTaskMemFree), not imported. */ +#include "setup_int.h" + +#include +#include + +#include "sd_l10n/sd_l10n.h" +#include "setup_flow.h" +#include "setup_ui.h" + +#define SD_UI_CLASS L"ShellDriveSetupWindow" +#define SD_UI_CLIENT_W 500 +#define SD_UI_CLIENT_H 330 +#define SD_UI_MARGIN 16 +#define SD_UI_TEXT_ID 101 /* progress status line */ +#define SD_UI_MAX_PAGE 24 /* child windows of one page */ +#define SD_UI_HISTORY 16 /* pages remembered for Back */ +#define SD_UI_BAR_TOP 110 +#define SD_UI_BAR_H 18 +#define SD_UI_BTN_Y 290 +#define SD_UI_BTN_H 26 +#define SD_UI_BTN_W 96 + +/* Control ids of the pages (the fixed buttons use the ones below them). */ +enum { + SD_ID_TITLE = 201, SD_ID_BODY, SD_ID_BODY2, SD_ID_NOTE, SD_ID_EDIT, SD_ID_BROWSE, SD_ID_OPT, SD_ID_LAUNCH, + SD_ID_R_USER, SD_ID_R_MACHINE, SD_ID_R_PORTABLE, SD_ID_LICENSE, SD_ID_REASON, + SD_ID_BACK = 301, SD_ID_NEXT, SD_ID_CANCEL +}; + +/* Result of the last button press or window message of a page. */ +enum { SD_ACT_NONE = 0, SD_ACT_BACK, SD_ACT_NEXT, SD_ACT_CANCEL }; + +struct sd_setup_ui { + HWND hwnd; + HINSTANCE inst; + HFONT font; + HWND back, next, cancel; /* fixed buttons, shown per page */ + HWND page[SD_UI_MAX_PAGE]; /* children of the current page, destroyed when the page is left */ + size_t npage; + HWND status; /* progress status line */ + HWND radio_user, radio_machine, radio_portable, edit, opt, launch; + unsigned percent; + int closed; + int action; + int failed; /* a control of a page could not be created */ + int progress; /* the progress view is shown */ + sd_flow_page shown; /* the page on the screen */ + wchar_t portable_dir[MAX_PATH]; + wchar_t uninstall_dir[MAX_PATH]; + sd_setup_dokany_plan dokany_plan; + sd_setup_mode default_mode; +}; + +/* Folder dialog and item-list helpers: shell32 and ole32 entry points resolved at run time (04 §2.4). */ +typedef LPITEMIDLIST (WINAPI *sd_SHBrowseForFolderW_t)(LPBROWSEINFOW); +typedef BOOL (WINAPI *sd_SHGetPathFromIDListW_t)(LPCITEMIDLIST, LPWSTR); +typedef void (WINAPI *sd_CoTaskMemFree_t)(LPVOID); + +static LRESULT CALLBACK ui_proc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp); +static void browse_folder(sd_setup_ui *ui); + +static void bar_rect(const sd_setup_ui *ui, RECT *bar) +{ + RECT client; + (void)ui; + GetClientRect(ui->hwnd, &client); + bar->left = SD_UI_MARGIN; + bar->right = client.right - SD_UI_MARGIN; + bar->top = SD_UI_BAR_TOP; + bar->bottom = bar->top + SD_UI_BAR_H; +} + +static LRESULT CALLBACK ui_proc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) +{ + sd_setup_ui *ui = (sd_setup_ui *)GetWindowLongPtrW(hwnd, GWLP_USERDATA); + + switch (msg) { + case WM_CREATE: { + CREATESTRUCTW *cs = (CREATESTRUCTW *)lp; + SetWindowLongPtrW(hwnd, GWLP_USERDATA, (LONG_PTR)cs->lpCreateParams); + return 0; + } + case WM_PAINT: { + PAINTSTRUCT ps; + HDC dc = BeginPaint(hwnd, &ps); + if (ui != NULL && ui->progress) { + RECT bar, fill; + HBRUSH back, front; + bar_rect(ui, &bar); + back = CreateSolidBrush(RGB(230, 230, 230)); + front = CreateSolidBrush(RGB(0, 120, 215)); + FillRect(dc, &bar, back); + fill = bar; + fill.right = bar.left + (LONG)((bar.right - bar.left) * (ui->percent > 100 ? 100 : ui->percent) / 100); + FillRect(dc, &fill, front); + DeleteObject(back); + DeleteObject(front); + } + EndPaint(hwnd, &ps); + return 0; + } + case WM_COMMAND: + if (ui != NULL) { + WORD id = LOWORD(wp); + WORD code = HIWORD(wp); + if (id == SD_ID_BACK && code == BN_CLICKED) + ui->action = SD_ACT_BACK; + else if (id == SD_ID_NEXT && code == BN_CLICKED) + ui->action = SD_ACT_NEXT; + else if (id == SD_ID_CANCEL && code == BN_CLICKED) + ui->action = SD_ACT_CANCEL; + else if (id == SD_ID_BROWSE && code == BN_CLICKED) + browse_folder(ui); + else if (id == IDOK) + ui->action = SD_ACT_NEXT; + else if (id == IDCANCEL) + ui->action = SD_ACT_CANCEL; + } + return 0; + case WM_CLOSE: + if (ui != NULL) { + ui->closed = 1; + ui->action = SD_ACT_CANCEL; + } + return 0; + default: + break; + } + return DefWindowProcW(hwnd, msg, wp, lp); +} + +static int register_class(HINSTANCE inst) +{ + WNDCLASSEXW wc; + memset(&wc, 0, sizeof(wc)); + wc.cbSize = sizeof(wc); + wc.lpfnWndProc = ui_proc; + wc.hInstance = inst; + wc.hCursor = LoadCursorW(NULL, (LPCWSTR)IDC_ARROW); + wc.hbrBackground = (HBRUSH)(COLOR_BTNFACE + 1); + wc.lpszClassName = SD_UI_CLASS; + if (RegisterClassExW(&wc) == 0 && GetLastError() != ERROR_CLASS_ALREADY_EXISTS) + return 1; + return 0; +} + +/* ---- text ---- */ + +/* Formats a catalog message with the given folder. Returns buf. */ +static const wchar_t *text_of(sd_msg_id id, const wchar_t *folder, wchar_t *buf, size_t cch) +{ + sd_setup_result r; + memset(&r, 0, sizeof(r)); + if (folder != NULL) + StringCchCopyW(r.folder, SD_SETUP_COUNTOF(r.folder), folder); + if (sd_setup_format_message(&r, id, buf, cch) < 0) + buf[0] = L'\0'; + return buf; +} + +/* ---- controls of a page ---- */ + +static HWND add_child(sd_setup_ui *ui, const wchar_t *cls, const wchar_t *text, DWORD style, DWORD exstyle, int x, + int y, int w, int h, int id) +{ + HWND c; + if (ui->npage >= SD_UI_MAX_PAGE) { + ui->failed = 1; + return NULL; + } + c = CreateWindowExW(exstyle, cls, text, WS_CHILD | WS_VISIBLE | style, x, y, w, h, ui->hwnd, + (HMENU)(INT_PTR)id, ui->inst, NULL); + if (c == NULL) { + ui->failed = 1; + return NULL; + } + if (ui->font != NULL) + SendMessageW(c, WM_SETFONT, (WPARAM)ui->font, FALSE); + ui->page[ui->npage++] = c; + return c; +} + +static HWND add_text(sd_setup_ui *ui, const wchar_t *text, int x, int y, int w, int h, int id) +{ + return add_child(ui, L"STATIC", text, SS_LEFT | SS_NOPREFIX, 0, x, y, w, h, id); +} + +static HWND add_heading(sd_setup_ui *ui, sd_msg_id title) +{ + wchar_t buf[256]; + return add_text(ui, text_of(title, NULL, buf, SD_SETUP_COUNTOF(buf)), SD_UI_MARGIN, 14, + SD_UI_CLIENT_W - 2 * SD_UI_MARGIN, 22, SD_ID_TITLE); +} + +static void clear_page(sd_setup_ui *ui) +{ + size_t i; + for (i = 0; i < ui->npage; i++) + DestroyWindow(ui->page[i]); + ui->npage = 0; + ui->radio_user = ui->radio_machine = ui->radio_portable = NULL; + ui->edit = ui->opt = ui->launch = ui->status = NULL; + ui->progress = 0; /* the progress bar is painted by the progress page only */ +} + +/* Shows or hides the fixed buttons and sets their state for the page. */ +static void set_buttons(sd_setup_ui *ui, int show, int back_on, sd_msg_id next_label) +{ + wchar_t buf[64]; + SetWindowTextW(ui->next, text_of(next_label, NULL, buf, SD_SETUP_COUNTOF(buf))); + ShowWindow(ui->back, show ? SW_SHOW : SW_HIDE); + ShowWindow(ui->next, show ? SW_SHOW : SW_HIDE); + ShowWindow(ui->cancel, show ? SW_SHOW : SW_HIDE); + EnableWindow(ui->back, back_on ? TRUE : FALSE); +} + +/* The page builders. Each creates its children and sets the buttons. */ + +static void build_welcome(sd_setup_ui *ui, int back_on) +{ + wchar_t buf[512]; + add_heading(ui, SD_MSG_INSTALLER_WELCOME_TITLE); + add_text(ui, text_of(SD_MSG_INSTALLER_WELCOME_TEXT, NULL, buf, SD_SETUP_COUNTOF(buf)), SD_UI_MARGIN, 48, + SD_UI_CLIENT_W - 2 * SD_UI_MARGIN, 80, SD_ID_BODY); + set_buttons(ui, 1, back_on, SD_MSG_INSTALLER_BUTTON_NEXT); +} + +static void build_license(sd_setup_ui *ui, int back_on, int last) +{ + wchar_t intro[512]; + wchar_t *body, *raw; + size_t i, k, n, cch = 8192; + + add_heading(ui, SD_MSG_INSTALLER_LICENSE_TITLE); + add_text(ui, text_of(SD_MSG_INSTALLER_LICENSE_INTRO, NULL, intro, SD_SETUP_COUNTOF(intro)), SD_UI_MARGIN, 40, + SD_UI_CLIENT_W - 2 * SD_UI_MARGIN, 34, SD_ID_BODY); + + /* The catalog keeps \n; an edit control needs \r\n. The text is copied with the breaks converted. */ + raw = (wchar_t *)HeapAlloc(GetProcessHeap(), 0, cch * sizeof(wchar_t)); + body = (wchar_t *)HeapAlloc(GetProcessHeap(), 0, cch * sizeof(wchar_t)); + if (raw != NULL && body != NULL) { + text_of(SD_MSG_INSTALLER_LICENSE_TEXT, NULL, raw, cch); + n = wcslen(raw); + k = 0; + for (i = 0; i < n && k + 2 < cch; i++) { + if (raw[i] == L'\n' && (i == 0 || raw[i - 1] != L'\r')) + body[k++] = L'\r'; + body[k++] = raw[i]; + } + body[k] = L'\0'; + add_child(ui, L"EDIT", body, ES_MULTILINE | ES_READONLY | ES_AUTOVSCROLL | WS_VSCROLL | WS_TABSTOP, + WS_EX_CLIENTEDGE, SD_UI_MARGIN, 80, SD_UI_CLIENT_W - 2 * SD_UI_MARGIN, 196, SD_ID_LICENSE); + } else { + ui->failed = 1; + } + if (raw != NULL) + HeapFree(GetProcessHeap(), 0, raw); + if (body != NULL) + HeapFree(GetProcessHeap(), 0, body); + set_buttons(ui, 1, back_on, last ? SD_MSG_INSTALLER_BUTTON_INSTALL : SD_MSG_INSTALLER_BUTTON_NEXT); +} + +static void build_mode(sd_setup_ui *ui, int back_on, int last) +{ + wchar_t buf[512]; + int x = SD_UI_MARGIN, w = SD_UI_CLIENT_W - 2 * SD_UI_MARGIN; + add_heading(ui, SD_MSG_INSTALLER_MODE_TITLE); + add_text(ui, text_of(SD_MSG_INSTALLER_MODE_TEXT, NULL, buf, SD_SETUP_COUNTOF(buf)), x, 44, w, 22, SD_ID_BODY); + + ui->radio_user = add_child(ui, L"BUTTON", text_of(SD_MSG_INSTALLER_MODE_USER, NULL, buf, SD_SETUP_COUNTOF(buf)), + BS_AUTORADIOBUTTON | WS_GROUP | WS_TABSTOP, 0, x + 8, 84, w - 8, 22, SD_ID_R_USER); + ui->radio_portable = add_child(ui, L"BUTTON", + text_of(SD_MSG_INSTALLER_MODE_PORTABLE, NULL, buf, SD_SETUP_COUNTOF(buf)), + BS_AUTORADIOBUTTON | WS_TABSTOP, 0, x + 8, 112, w - 8, 22, SD_ID_R_PORTABLE); + /* The all-users choice: the elevated two-stage install (setup_elevate.c). Its explanation is shown under it. */ + ui->radio_machine = add_child(ui, L"BUTTON", + text_of(SD_MSG_INSTALLER_MODE_MACHINE, NULL, buf, SD_SETUP_COUNTOF(buf)), + BS_AUTORADIOBUTTON | WS_TABSTOP, 0, + x + 8, 140, w - 8, 22, SD_ID_R_MACHINE); + add_text(ui, text_of(SD_MSG_INSTALLER_MODE_MACHINE_REASON, NULL, buf, SD_SETUP_COUNTOF(buf)), x + 28, 170, w - 28, + 44, SD_ID_REASON); + + if (ui->radio_user != NULL) + SendMessageW(ui->radio_user, BM_SETCHECK, + (ui->default_mode != SD_SETUP_MODE_PORTABLE && ui->default_mode != SD_SETUP_MODE_MACHINE) + ? BST_CHECKED : BST_UNCHECKED, 0); + if (ui->radio_portable != NULL) + SendMessageW(ui->radio_portable, BM_SETCHECK, + ui->default_mode == SD_SETUP_MODE_PORTABLE ? BST_CHECKED : BST_UNCHECKED, 0); + if (ui->radio_machine != NULL) + SendMessageW(ui->radio_machine, BM_SETCHECK, + ui->default_mode == SD_SETUP_MODE_MACHINE ? BST_CHECKED : BST_UNCHECKED, 0); + set_buttons(ui, 1, back_on, last ? SD_MSG_INSTALLER_BUTTON_INSTALL : SD_MSG_INSTALLER_BUTTON_NEXT); +} + +/* Folder dialog of the destination page. The result goes into the edit box. */ +static void browse_folder(sd_setup_ui *ui) +{ + sd_SHBrowseForFolderW_t browse = (sd_SHBrowseForFolderW_t)sd_setup_proc(L"shell32.dll", "SHBrowseForFolderW"); + sd_SHGetPathFromIDListW_t to_path = (sd_SHGetPathFromIDListW_t)sd_setup_proc(L"shell32.dll", "SHGetPathFromIDListW"); + sd_CoTaskMemFree_t free_pidl = (sd_CoTaskMemFree_t)sd_setup_proc(L"ole32.dll", "CoTaskMemFree"); + BROWSEINFOW bi; + LPITEMIDLIST pidl; + wchar_t path[MAX_PATH]; + wchar_t title[256]; + + if (browse == NULL || to_path == NULL) + return; + memset(&bi, 0, sizeof(bi)); + bi.hwndOwner = ui->hwnd; + bi.lpszTitle = text_of(SD_MSG_INSTALLER_DEST_TITLE, NULL, title, SD_SETUP_COUNTOF(title)); + bi.ulFlags = BIF_RETURNONLYFSDIRS; + pidl = browse(&bi); + if (pidl == NULL) + return; + if (to_path(pidl, path)) + SetWindowTextW(ui->edit, path); + if (free_pidl != NULL) + free_pidl(pidl); +} + +static void build_destination(sd_setup_ui *ui, int back_on, int last) +{ + wchar_t buf[512]; + int x = SD_UI_MARGIN, w = SD_UI_CLIENT_W - 2 * SD_UI_MARGIN; + add_heading(ui, SD_MSG_INSTALLER_DEST_TITLE); + add_text(ui, text_of(SD_MSG_INSTALLER_DEST_TEXT, NULL, buf, SD_SETUP_COUNTOF(buf)), x, 44, w, 40, SD_ID_BODY); + ui->edit = add_child(ui, L"EDIT", ui->portable_dir, ES_AUTOHSCROLL | WS_TABSTOP, WS_EX_CLIENTEDGE, x, 98, 370, 24, + SD_ID_EDIT); + add_child(ui, L"BUTTON", text_of(SD_MSG_INSTALLER_BUTTON_BROWSE, NULL, buf, SD_SETUP_COUNTOF(buf)), + BS_PUSHBUTTON | WS_TABSTOP, 0, x + 380, 97, w - 380, SD_UI_BTN_H - 2, SD_ID_BROWSE); + set_buttons(ui, 1, back_on, last ? SD_MSG_INSTALLER_BUTTON_INSTALL : SD_MSG_INSTALLER_BUTTON_NEXT); +} + +static void build_dokany(sd_setup_ui *ui, int back_on, int last) +{ + wchar_t buf[512]; + sd_flow_dokany_view v; + int x = SD_UI_MARGIN, w = SD_UI_CLIENT_W - 2 * SD_UI_MARGIN; + + sd_flow_dokany_view_of(ui->dokany_plan, &v); + add_heading(ui, SD_MSG_INSTALLER_DOKANY_TITLE); + add_text(ui, text_of(SD_MSG_INSTALLER_DOKANY_TEXT, NULL, buf, SD_SETUP_COUNTOF(buf)), x, 44, w, 120, SD_ID_BODY); + ui->opt = add_child(ui, L"BUTTON", text_of(SD_MSG_INSTALLER_DOKANY_OPTION, NULL, buf, SD_SETUP_COUNTOF(buf)), + BS_AUTOCHECKBOX | WS_TABSTOP | (v.enabled ? 0 : WS_DISABLED), 0, x, 176, w, 22, SD_ID_OPT); + if (ui->opt != NULL) + SendMessageW(ui->opt, BM_SETCHECK, v.checked ? BST_CHECKED : BST_UNCHECKED, 0); + if (v.has_note) + add_text(ui, text_of(v.note, NULL, buf, SD_SETUP_COUNTOF(buf)), x + 20, 206, w - 20, 44, SD_ID_NOTE); + set_buttons(ui, 1, back_on, last ? SD_MSG_INSTALLER_BUTTON_INSTALL : SD_MSG_INSTALLER_BUTTON_NEXT); +} + +static void build_progress(sd_setup_ui *ui) +{ + ui->progress = 1; + ui->status = add_child(ui, L"STATIC", L"", SS_LEFT | SS_NOPREFIX, 0, SD_UI_MARGIN, 56, + SD_UI_CLIENT_W - 2 * SD_UI_MARGIN, 40, SD_UI_TEXT_ID); + set_buttons(ui, 0, 0, SD_MSG_INSTALLER_BUTTON_NEXT); + InvalidateRect(ui->hwnd, NULL, TRUE); +} + +static void build_finish(sd_setup_ui *ui, const wchar_t *text) +{ + wchar_t buf[512]; + int x = SD_UI_MARGIN, w = SD_UI_CLIENT_W - 2 * SD_UI_MARGIN; + ui->progress = 0; + add_heading(ui, SD_MSG_INSTALLER_FINISH_TITLE); + add_text(ui, text != NULL ? text : L"", x, 44, w, 60, SD_ID_BODY); + ui->launch = add_child(ui, L"BUTTON", text_of(SD_MSG_INSTALLER_FINISH_LAUNCH, NULL, buf, SD_SETUP_COUNTOF(buf)), + BS_AUTOCHECKBOX | WS_TABSTOP, 0, x, 120, w, 22, SD_ID_LAUNCH); + if (ui->launch != NULL) + SendMessageW(ui->launch, BM_SETCHECK, BST_CHECKED, 0); + set_buttons(ui, 1, 0, SD_MSG_INSTALLER_BUTTON_FINISH); + ShowWindow(ui->back, SW_HIDE); + ShowWindow(ui->cancel, SW_HIDE); +} + +static void build_uninstall(sd_setup_ui *ui, int back_on) +{ + wchar_t buf[512]; + int x = SD_UI_MARGIN, w = SD_UI_CLIENT_W - 2 * SD_UI_MARGIN; + add_heading(ui, SD_MSG_INSTALLER_UNINSTALL_TITLE); + add_text(ui, text_of(SD_MSG_INSTALLER_UNINSTALL_TEXT, ui->uninstall_dir, buf, SD_SETUP_COUNTOF(buf)), x, 44, w, 60, + SD_ID_BODY); + add_text(ui, text_of(SD_MSG_INSTALLER_UNINSTALL_QUESTION, NULL, buf, SD_SETUP_COUNTOF(buf)), x, 120, w, 44, + SD_ID_BODY2); + set_buttons(ui, 1, back_on, SD_MSG_INSTALLER_BUTTON_UNINSTALL); +} + +/* Builds the page cur. back_on: Back has a page to return to. last: the page is the one before the progress. */ +static void show_page(sd_setup_ui *ui, sd_flow_page cur, int back_on, int last) +{ + clear_page(ui); + ui->shown = cur; + switch (cur) { + case SD_FLOW_WELCOME: build_welcome(ui, back_on); break; + case SD_FLOW_LICENSE: build_license(ui, back_on, last); break; + case SD_FLOW_MODE: build_mode(ui, back_on, last); break; + case SD_FLOW_DESTINATION: build_destination(ui, back_on, last); break; + case SD_FLOW_DOKANY: build_dokany(ui, back_on, last); break; + case SD_FLOW_PROGRESS: build_progress(ui); break; + case SD_FLOW_FINISH: build_finish(ui, L""); break; + case SD_FLOW_UNINSTALL: build_uninstall(ui, back_on); break; + case SD_FLOW_NONE: + default: + break; + } +} + +/* Waits for a button press or a close. Messages for the dialog keys (Tab, Enter, Esc) go through IsDialogMessage. */ +static int wait_action(sd_setup_ui *ui) +{ + MSG msg; + ui->action = SD_ACT_NONE; + while (ui->action == SD_ACT_NONE) { + if (GetMessageW(&msg, NULL, 0, 0) <= 0) { + ui->action = SD_ACT_CANCEL; + break; + } + if (!IsDialogMessageW(ui->hwnd, &msg)) { + TranslateMessage(&msg); + DispatchMessageW(&msg); + } + } + return ui->action; +} + +/* Reads the answers of the page that is being left. Returns 0 when the answer is not usable (stay on the page). */ +static int read_page(sd_setup_ui *ui, sd_flow_page cur, sd_setup_choices *c) +{ + switch (cur) { + case SD_FLOW_MODE: + if (ui->radio_portable != NULL && SendMessageW(ui->radio_portable, BM_GETCHECK, 0, 0) == BST_CHECKED) + c->mode = SD_SETUP_MODE_PORTABLE; + else if (ui->radio_machine != NULL && SendMessageW(ui->radio_machine, BM_GETCHECK, 0, 0) == BST_CHECKED) + c->mode = SD_SETUP_MODE_MACHINE; + else + c->mode = SD_SETUP_MODE_USER; + return 1; + case SD_FLOW_DESTINATION: { + wchar_t folder[MAX_PATH]; + GetWindowTextW(ui->edit, folder, SD_SETUP_COUNTOF(folder)); + if (folder[0] == L'\0') { + SetFocus(ui->edit); + return 0; + } + StringCchCopyW(c->portable_dir, SD_SETUP_COUNTOF(c->portable_dir), folder); + return 1; + } + case SD_FLOW_DOKANY: + c->dokany = ui->opt != NULL && SendMessageW(ui->opt, BM_GETCHECK, 0, 0) == BST_CHECKED ? 1 : 0; + return 1; + default: + return 1; + } +} + +/* Runs the pages of the flow. The Back history is kept here; the window shows one page at a time. */ +int sd_setup_ui_run_pages(sd_setup_ui *ui, const sd_flow_input *in, const sd_setup_defaults *d, sd_setup_choices *out) +{ + sd_flow_choice fc; + sd_flow_page history[SD_UI_HISTORY]; + size_t nh = 0; + sd_flow_page cur; + int act; + + if (ui == NULL || in == NULL || d == NULL || out == NULL) + return 0; + memset(out, 0, sizeof(*out)); + StringCchCopyW(ui->uninstall_dir, SD_SETUP_COUNTOF(ui->uninstall_dir), d->uninstall_dir ? d->uninstall_dir : L""); + StringCchCopyW(ui->portable_dir, SD_SETUP_COUNTOF(ui->portable_dir), d->portable_dir ? d->portable_dir : L""); + ui->dokany_plan = d->dokany_plan; + ui->default_mode = d->mode; + + out->mode = (d->mode == SD_SETUP_MODE_AUTO) ? SD_SETUP_MODE_USER : d->mode; + StringCchCopyW(out->portable_dir, SD_SETUP_COUNTOF(out->portable_dir), ui->portable_dir); + cur = sd_flow_first(in); + while (cur != SD_FLOW_NONE && cur != SD_FLOW_PROGRESS) { + sd_flow_page next; + int last; + + fc.mode = out->mode; + next = sd_flow_next(in, &fc, cur); + /* the page before the progress view: its button says Install (the mode page says Next: its answer decides) */ + last = (next == SD_FLOW_PROGRESS) && cur != SD_FLOW_MODE && cur != SD_FLOW_UNINSTALL; + ui->failed = 0; + show_page(ui, cur, nh > 0, last); + if (ui->failed) { + clear_page(ui); + return 0; + } + act = wait_action(ui); + if (act == SD_ACT_CANCEL) { + clear_page(ui); + return 0; + } + if (act == SD_ACT_BACK) { + if (nh > 0) { + clear_page(ui); + cur = history[--nh]; + } + continue; + } + if (!read_page(ui, cur, out)) + continue; + if (nh < SD_UI_HISTORY) + history[nh++] = cur; + clear_page(ui); + fc.mode = out->mode; + cur = sd_flow_next(in, &fc, cur); + } + clear_page(ui); + return cur == SD_FLOW_PROGRESS ? 1 : 0; +} + +/* The page names, in order, written to the setup log. Nothing is answered and nothing is installed. */ +int sd_setup_ui_test_pages(sd_setup_ui *ui, const sd_flow_input *in, const sd_setup_defaults *d, unsigned *built) +{ + sd_flow_choice c; + sd_flow_page cur; + unsigned n = 0; + + if (built != NULL) + *built = 0; + if (ui == NULL || in == NULL || d == NULL) + return 0; + StringCchCopyW(ui->uninstall_dir, SD_SETUP_COUNTOF(ui->uninstall_dir), d->uninstall_dir ? d->uninstall_dir : L""); + StringCchCopyW(ui->portable_dir, SD_SETUP_COUNTOF(ui->portable_dir), d->portable_dir ? d->portable_dir : L""); + ui->dokany_plan = d->dokany_plan; + ui->default_mode = d->mode; + c.mode = (d->mode == SD_SETUP_MODE_AUTO) ? SD_SETUP_MODE_USER : d->mode; + + ui->failed = 0; + cur = sd_flow_first(in); + while (cur != SD_FLOW_NONE && n < 32) { + wchar_t name[32]; + char ascii[32]; + if (cur == SD_FLOW_PROGRESS) + build_progress(ui); + else if (cur == SD_FLOW_FINISH) + build_finish(ui, L""); + else + show_page(ui, cur, n > 0, 0); + if (ui->failed) + break; + /* the page is painted and handled once, then removed */ + sd_setup_ui_pump(ui); + StringCchCopyA(ascii, SD_SETUP_COUNTOF(ascii), sd_flow_page_name(cur)); + sd_setup_ascii_to_wide(ascii, name, SD_SETUP_COUNTOF(name)); + sd_setup_log("test.page", name); + clear_page(ui); + n++; + cur = sd_flow_next(in, &c, cur); + } + if (built != NULL) + *built = n; + return ui->failed ? 0 : 1; +} + +void sd_setup_ui_begin_progress(sd_setup_ui *ui) +{ + if (ui == NULL) + return; + clear_page(ui); + ui->shown = SD_FLOW_PROGRESS; + build_progress(ui); +} + +void sd_setup_ui_status(sd_setup_ui *ui, const wchar_t *text, unsigned percent) +{ + RECT bar; + if (ui == NULL) + return; + ui->percent = percent; + if (ui->status != NULL) + SetWindowTextW(ui->status, text != NULL ? text : L""); + if (ui->hwnd != NULL) { + bar_rect(ui, &bar); + InvalidateRect(ui->hwnd, &bar, TRUE); + } + sd_setup_ui_pump(ui); +} + +int sd_setup_ui_pump(sd_setup_ui *ui) +{ + MSG msg; + if (ui == NULL) + return 1; + while (PeekMessageW(&msg, NULL, 0, 0, PM_REMOVE)) { + TranslateMessage(&msg); + DispatchMessageW(&msg); + } + return !ui->closed; +} + +int sd_setup_ui_finish(sd_setup_ui *ui, const wchar_t *text, int *launch) +{ + int act; + if (launch != NULL) + *launch = 0; + if (ui == NULL) + return 0; + clear_page(ui); + build_finish(ui, text); + act = wait_action(ui); + if (act == SD_ACT_NEXT) { + if (launch != NULL && ui->launch != NULL) + *launch = SendMessageW(ui->launch, BM_GETCHECK, 0, 0) == BST_CHECKED ? 1 : 0; + return 1; + } + return 0; +} + +void sd_setup_ui_message(HWND owner, const wchar_t *title, const wchar_t *text, int is_error) +{ + MessageBoxW(owner, text, title, MB_OK | (is_error ? MB_ICONERROR : MB_ICONINFORMATION)); +} + +/* Creates the window with its fixed buttons. The client area is SD_UI_CLIENT_W x SD_UI_CLIENT_H. */ +int sd_setup_ui_create(HINSTANCE inst, const wchar_t *title, sd_setup_ui **out) +{ + sd_setup_ui *ui; + RECT rc; + DWORD style = WS_OVERLAPPED | WS_CAPTION | WS_SYSMENU | WS_MINIMIZEBOX; + wchar_t buf[64]; + + *out = NULL; + if (register_class(inst) != 0) + return 1; + ui = (sd_setup_ui *)HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, sizeof(*ui)); + if (ui == NULL) + return 1; + ui->inst = inst; + ui->font = (HFONT)GetStockObject(DEFAULT_GUI_FONT); + + SetRect(&rc, 0, 0, SD_UI_CLIENT_W, SD_UI_CLIENT_H); + AdjustWindowRectEx(&rc, style, FALSE, 0); + ui->hwnd = CreateWindowExW(0, SD_UI_CLASS, title, style, CW_USEDEFAULT, CW_USEDEFAULT, rc.right - rc.left, + rc.bottom - rc.top, NULL, NULL, inst, ui); + if (ui->hwnd == NULL) { + HeapFree(GetProcessHeap(), 0, ui); + return 1; + } + ui->back = CreateWindowExW(0, L"BUTTON", text_of(SD_MSG_INSTALLER_BUTTON_BACK, NULL, buf, SD_SETUP_COUNTOF(buf)), + WS_CHILD | WS_TABSTOP | BS_PUSHBUTTON, SD_UI_CLIENT_W - 3 * SD_UI_BTN_W - 24 - 8, + SD_UI_BTN_Y, SD_UI_BTN_W, SD_UI_BTN_H, ui->hwnd, (HMENU)(INT_PTR)SD_ID_BACK, inst, NULL); + ui->next = CreateWindowExW(0, L"BUTTON", text_of(SD_MSG_INSTALLER_BUTTON_NEXT, NULL, buf, SD_SETUP_COUNTOF(buf)), + WS_CHILD | WS_TABSTOP | BS_DEFPUSHBUTTON, SD_UI_CLIENT_W - 2 * SD_UI_BTN_W - 24, + SD_UI_BTN_Y, SD_UI_BTN_W, SD_UI_BTN_H, ui->hwnd, (HMENU)(INT_PTR)SD_ID_NEXT, inst, NULL); + ui->cancel = CreateWindowExW(0, L"BUTTON", text_of(SD_MSG_INSTALLER_BUTTON_CANCEL, NULL, buf, SD_SETUP_COUNTOF(buf)), + WS_CHILD | WS_TABSTOP | BS_PUSHBUTTON, SD_UI_CLIENT_W - SD_UI_BTN_W - SD_UI_MARGIN, + SD_UI_BTN_Y, SD_UI_BTN_W, SD_UI_BTN_H, ui->hwnd, (HMENU)(INT_PTR)SD_ID_CANCEL, inst, + NULL); + if (ui->back == NULL || ui->next == NULL || ui->cancel == NULL) { + DestroyWindow(ui->hwnd); + HeapFree(GetProcessHeap(), 0, ui); + return 1; + } + SendMessageW(ui->back, WM_SETFONT, (WPARAM)ui->font, FALSE); + SendMessageW(ui->next, WM_SETFONT, (WPARAM)ui->font, FALSE); + SendMessageW(ui->cancel, WM_SETFONT, (WPARAM)ui->font, FALSE); + ShowWindow(ui->back, SW_HIDE); + ShowWindow(ui->next, SW_HIDE); + ShowWindow(ui->cancel, SW_HIDE); + ShowWindow(ui->hwnd, SW_SHOWNORMAL); + UpdateWindow(ui->hwnd); + *out = ui; + return 0; +} + +void sd_setup_ui_destroy(sd_setup_ui *ui) +{ + if (ui == NULL) + return; + clear_page(ui); + if (ui->hwnd != NULL) + DestroyWindow(ui->hwnd); + HeapFree(GetProcessHeap(), 0, ui); +} diff --git a/apps/shelldrive-setup/setup_ui.h b/apps/shelldrive-setup/setup_ui.h new file mode 100644 index 0000000..0821f3b --- /dev/null +++ b/apps/shelldrive-setup/setup_ui.h @@ -0,0 +1,69 @@ +/* shelldrive-setup: the wizard window (docs/design/04 §6.3, §6.5, §7.3, §13). + * + * One window with plain Win32 controls (BUTTON, STATIC, EDIT) and no common controls: the stub keeps the + * KnownDLL-only import rule, and comctl32 is not a KnownDLL (tests/os-imports/knowndlls.txt). The pages come + * from setup_flow.c; the texts come from the installer catalog (l10n/installer). Pages are built on demand + * and destroyed when the user leaves them. The progress view and the finish page use the same window. + * + * Call order of an interactive install: run_pages (stops before the progress page), begin_progress, the engine + * with status as its progress callback, finish (the final page). /passive calls begin_progress directly; /quiet + * creates no window at all. */ +#ifndef SD_SETUP_UI_H +#define SD_SETUP_UI_H + +#include + +#include "setup_core.h" +#include "setup_flow.h" + +typedef struct sd_setup_ui sd_setup_ui; + +/* What the user chose on the pages. */ +typedef struct sd_setup_choices { + sd_setup_mode mode; /* SD_SETUP_MODE_USER or SD_SETUP_MODE_PORTABLE (MACHINE is never offered) */ + wchar_t portable_dir[MAX_PATH]; + int dokany; /* 1 when the Dokany checkbox is checked (D36) */ + int launch; /* the finish page: start ShellDrive */ +} sd_setup_choices; + +/* What the pages show before the user answers. */ +typedef struct sd_setup_defaults { + sd_setup_mode mode; /* the mode given on the command line, or SD_SETUP_MODE_AUTO */ + const wchar_t *portable_dir; /* suggested folder of a portable copy; "" for none */ + sd_setup_dokany_plan dokany_plan; + const wchar_t *uninstall_dir; /* folder named on the removal page */ +} sd_setup_defaults; + +/* Creates the window, visible and empty. Returns 0 or 1. */ +int sd_setup_ui_create(HINSTANCE inst, const wchar_t *title, sd_setup_ui **out); + +/* Runs the interactive pages from the first one of the flow up to the progress page (which is not built here), + or the removal confirmation for in->uninstall. Returns 1 when the user chose to go on, 0 when they cancelled or + closed the window. out receives the answers. */ +int sd_setup_ui_run_pages(sd_setup_ui *ui, const sd_flow_input *in, const sd_setup_defaults *d, + sd_setup_choices *out); + +/* Builds every page of the flow once, in order, without any answer: the page names are written to the setup log + (event "test.page") and each page is destroyed again. *built gets the number of pages built. Returns 1 when + every page was created (0 otherwise). Used by the hidden /test-pages switch: nothing is installed. */ +int sd_setup_ui_test_pages(sd_setup_ui *ui, const sd_flow_input *in, const sd_setup_defaults *d, unsigned *built); + +/* Shows the progress view (status line and bar). Used after run_pages, or directly for /passive. */ +void sd_setup_ui_begin_progress(sd_setup_ui *ui); + +/* Sets the status line and the progress percentage (0..100), then pumps pending messages. */ +void sd_setup_ui_status(sd_setup_ui *ui, const wchar_t *text, unsigned percent); + +/* Pumps pending messages. Returns 0 once the user has closed the window (a cancel request). */ +int sd_setup_ui_pump(sd_setup_ui *ui); + +/* Shows the finish page with text and waits for Finish. *launch gets the "Launch ShellDrive" checkbox. Returns 1 + after Finish, 0 when the window was closed. */ +int sd_setup_ui_finish(sd_setup_ui *ui, const wchar_t *text, int *launch); + +/* Modal message for the final result (or the error) of the operation. */ +void sd_setup_ui_message(HWND owner, const wchar_t *title, const wchar_t *text, int is_error); + +void sd_setup_ui_destroy(sd_setup_ui *ui); + +#endif /* SD_SETUP_UI_H */ diff --git a/apps/shelldrive-setup/setup_util.c b/apps/shelldrive-setup/setup_util.c new file mode 100644 index 0000000..af3cc81 --- /dev/null +++ b/apps/shelldrive-setup/setup_util.c @@ -0,0 +1,225 @@ +/* shelldrive-setup: small helpers (run-time exports, paths, random names, tree removal). */ +#include "setup_int.h" + +#include +#include +#include + +FARPROC sd_setup_proc(const wchar_t *module, const char *name) +{ + HMODULE m = GetModuleHandleW(module); + if (m == NULL) { + wchar_t path[MAX_PATH]; + if (sd_setup_system_path(module, path, SD_SETUP_COUNTOF(path)) == 0) + m = LoadLibraryW(path); + } + if (m == NULL) + return NULL; + return GetProcAddress(m, name); +} + +int sd_setup_system_path(const wchar_t *file, wchar_t *out, size_t cch) +{ + wchar_t dir[MAX_PATH]; + UINT n = GetSystemDirectoryW(dir, SD_SETUP_COUNTOF(dir)); + if (n == 0 || n >= SD_SETUP_COUNTOF(dir)) + return 1; + return sd_setup_path_join(out, cch, dir, file); +} + +int sd_setup_dir_with_slash(wchar_t *dst, size_t cch, const wchar_t *dir) +{ + size_t n; + if (FAILED(StringCchCopyW(dst, cch, dir))) + return 1; + n = wcslen(dst); + if (n > 0 && dst[n - 1] != L'\\') { + if (n + 2 > cch) + return 1; + dst[n] = L'\\'; + dst[n + 1] = L'\0'; + } + return 0; +} + +int sd_setup_path_join(wchar_t *dst, size_t cch, const wchar_t *dir, const wchar_t *name) +{ + size_t n; + if (sd_setup_dir_with_slash(dst, cch, dir) != 0) + return 1; + n = wcslen(dst); + if (FAILED(StringCchCopyW(dst + n, cch - n, name))) + return 1; + return 0; +} + +int sd_setup_special_folder(int csidl, wchar_t *out, size_t cch) +{ + wchar_t path[MAX_PATH]; + if (cch < MAX_PATH) + return 1; + if (FAILED(SHGetFolderPathW(NULL, csidl, NULL, SHGFP_TYPE_CURRENT, path))) + return 1; + return FAILED(StringCchCopyW(out, cch, path)) ? 1 : 0; +} + +int sd_setup_random_hex(wchar_t *out, size_t cch) +{ + HCRYPTPROV prov = 0; + unsigned char buf[16]; + + memset(buf, 0, sizeof(buf)); + int i; + + if (cch < 33) + return 1; + if (!CryptAcquireContextW(&prov, NULL, NULL, PROV_RSA_FULL, CRYPT_VERIFYCONTEXT)) + return 1; + if (!CryptGenRandom(prov, sizeof(buf), buf)) { + CryptReleaseContext(prov, 0); + return 1; + } + CryptReleaseContext(prov, 0); + for (i = 0; i < 16; i++) { + static const wchar_t digits[] = L"0123456789abcdef"; + out[2 * i] = digits[buf[i] >> 4]; + out[2 * i + 1] = digits[buf[i] & 15]; + } + out[32] = L'\0'; + return 0; +} + +int sd_setup_temp_dir(wchar_t *out, size_t cch) +{ + DWORD n = GetTempPathW((DWORD)cch, out); + return (n == 0 || n >= cch) ? 1 : 0; +} + +static void remove_one(const wchar_t *path, unsigned *failed) +{ + if (!DeleteFileW(path)) { + DWORD attrs = GetFileAttributesW(path); + if (attrs != INVALID_FILE_ATTRIBUTES && (attrs & FILE_ATTRIBUTE_READONLY)) { + SetFileAttributesW(path, attrs & ~FILE_ATTRIBUTE_READONLY); + if (DeleteFileW(path)) + return; + } + (*failed)++; + } +} + +static void remove_tree_rec(const wchar_t *dir, unsigned *failed, int depth) +{ + WIN32_FIND_DATAW fd; + HANDLE find; + wchar_t pattern[MAX_PATH]; + + if (depth > 32) { + (*failed)++; + return; + } + if (sd_setup_path_join(pattern, MAX_PATH, dir, L"*") != 0) + return; + find = FindFirstFileW(pattern, &fd); + if (find == INVALID_HANDLE_VALUE) + return; + do { + wchar_t child[MAX_PATH]; + if (wcscmp(fd.cFileName, L".") == 0 || wcscmp(fd.cFileName, L"..") == 0) + continue; + if (sd_setup_path_join(child, MAX_PATH, dir, fd.cFileName) != 0) { + (*failed)++; + continue; + } + if (fd.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) { + /* Junctions and links are removed, never followed (TM-R28). */ + if (fd.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) { + if (!RemoveDirectoryW(child)) + (*failed)++; + } else { + remove_one(child, failed); + } + } else if (fd.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) { + remove_tree_rec(child, failed, depth + 1); + if (!RemoveDirectoryW(child)) + (*failed)++; + } else { + remove_one(child, failed); + } + } while (FindNextFileW(find, &fd)); + FindClose(find); +} + +int sd_setup_remove_tree(const wchar_t *dir, unsigned *failed) +{ + unsigned local = 0; + DWORD attrs = GetFileAttributesW(dir); + if (attrs == INVALID_FILE_ATTRIBUTES) + return 0; + if (failed == NULL) + failed = &local; + if (!(attrs & FILE_ATTRIBUTE_DIRECTORY)) { + remove_one(dir, failed); + return 0; + } + remove_tree_rec(dir, failed, 0); + if (!RemoveDirectoryW(dir) && GetLastError() != ERROR_FILE_NOT_FOUND) + (*failed)++; + return 0; +} + +DWORD sd_setup_mkdirs(const wchar_t *dir) +{ + wchar_t path[MAX_PATH]; + size_t i, n; + + if (FAILED(StringCchCopyW(path, MAX_PATH, dir))) + return ERROR_INVALID_PARAMETER; + n = wcslen(path); + while (n > 0 && path[n - 1] == L'\\') { + path[--n] = L'\0'; + } + for (i = 3; i < n; i++) { + if (path[i] == L'\\') { + path[i] = L'\0'; + if (!CreateDirectoryW(path, NULL) && GetLastError() != ERROR_ALREADY_EXISTS) { + return GetLastError(); + } + path[i] = L'\\'; + } + } + if (!CreateDirectoryW(path, NULL) && GetLastError() != ERROR_ALREADY_EXISTS) + return GetLastError(); + return 0; +} + +int sd_setup_ascii_to_wide(const char *src, wchar_t *dst, size_t cch) +{ + size_t i; + if (src == NULL || cch == 0) + return 1; + for (i = 0; src[i] != '\0'; i++) { + if (i + 1 >= cch || (unsigned char)src[i] >= 0x80) + return 1; + dst[i] = (wchar_t)(unsigned char)src[i]; + } + dst[i] = L'\0'; + return 0; +} + +int sd_setup_fail(sd_setup_result *r, int exit_code, sd_msg_id msg, long code) +{ + if (r != NULL) { + r->exit_code = exit_code; + r->msg = msg; + r->code = code; + } + return exit_code; +} + +void sd_setup_log_error(const char *event, const wchar_t *what, DWORD err) +{ + wchar_t line[MAX_PATH * 2]; + StringCchPrintfW(line, SD_SETUP_COUNTOF(line), L"%s (error %lu)", what ? what : L"", (unsigned long)err); + sd_setup_log(event, line); +} diff --git a/apps/shelldrive-shellext/CMakeLists.txt b/apps/shelldrive-shellext/CMakeLists.txt new file mode 100644 index 0000000..a83a50d --- /dev/null +++ b/apps/shelldrive-shellext/CMakeLists.txt @@ -0,0 +1,23 @@ +# shelldrive-shellext.dll: classic Explorer context menu handler for ShellDrive drives (03 §1.6, §8.2, §8.5). +# +# Shipping DLL, one per architecture. Built with sd_add_dll (version resource, import audit, ISA audit). +# SD_SHELLEXT_SOURCES is also used by tests/shellext to build the test-hook variant of the same code. + +set(SD_SHELLEXT_SOURCES + ${CMAKE_CURRENT_SOURCE_DIR}/shellext_com.c + ${CMAKE_CURRENT_SOURCE_DIR}/shellext_cache.c + ${CMAKE_CURRENT_SOURCE_DIR}/shellext_model.c + ${CMAKE_CURRENT_SOURCE_DIR}/shellext_register.c + ${CMAKE_CURRENT_SOURCE_DIR}/shellext_snapshot.c + ${CMAKE_CURRENT_SOURCE_DIR}/shellext_int.h + ${CMAKE_CURRENT_SOURCE_DIR}/shellext_model.h + ${CMAKE_CURRENT_SOURCE_DIR}/shellext_snapshot.h) +set(SD_SHELLEXT_SOURCES ${SD_SHELLEXT_SOURCES} PARENT_SCOPE) + +sd_add_dll(shelldrive-shellext + OUTPUT_NAME shelldrive-shellext + DESCRIPTION "ShellDrive Explorer context menu" + DEF ${CMAKE_CURRENT_SOURCE_DIR}/shelldrive-shellext.def + SOURCES ${SD_SHELLEXT_SOURCES} + DEPS sd_ipc sd_l10n sd_l10n_catalog_shell sd_platform sd_base + advapi32 ole32 shell32 user32) diff --git a/apps/shelldrive-shellext/shelldrive-shellext.def b/apps/shelldrive-shellext/shelldrive-shellext.def new file mode 100644 index 0000000..f5e857f --- /dev/null +++ b/apps/shelldrive-shellext/shelldrive-shellext.def @@ -0,0 +1,6 @@ +; shelldrive-shellext.dll exports (03 8.5). Undecorated names: the same list on x86 (__stdcall) and x64. +EXPORTS + DllCanUnloadNow PRIVATE + DllGetClassObject PRIVATE + DllRegisterServer PRIVATE + DllUnregisterServer PRIVATE diff --git a/apps/shelldrive-shellext/shellext_cache.c b/apps/shelldrive-shellext/shellext_cache.c new file mode 100644 index 0000000..c362075 --- /dev/null +++ b/apps/shelldrive-shellext/shellext_cache.c @@ -0,0 +1,113 @@ +/* Loader of menu.cache for the This PC submenu (03 §8.2). The file is untrusted input: it is opened read-only, + * must be owned by the user (or by the default owner of the user's token, which is what a file the user's agent + * creates gets), is capped at SHX_CACHE_MAX_BYTES, and goes through the bounded parser of shellext_model.c. + * Nothing here touches the network, the IPC pipe or a profile file. A missing or refused file is "no data". */ +#include +#include +#include +#include + +#include "sd_base/sd_mem.h" +#include "shellext_int.h" +#include "shellext_model.h" + +#ifndef CSIDL_LOCAL_APPDATA +#define CSIDL_LOCAL_APPDATA 0x001c +#endif + +/* 1 when the owner of the open file is the user or the default owner of the process token. */ +static int cache_owner_ok(HANDLE file) +{ + PSID owner = NULL; + PSECURITY_DESCRIPTOR sd = NULL; + HANDLE tok = NULL; + TOKEN_USER *tu = NULL; + TOKEN_OWNER *to = NULL; + DWORD len = 0; + int ok = 0; + + if (GetSecurityInfo(file, SE_FILE_OBJECT, OWNER_SECURITY_INFORMATION, &owner, NULL, NULL, NULL, &sd) != + ERROR_SUCCESS || + owner == NULL) + return 0; + if (!OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &tok)) + goto done; + GetTokenInformation(tok, TokenUser, NULL, 0, &len); + if (len != 0) { + tu = (TOKEN_USER *)sd_alloc(len); + if (tu != NULL && GetTokenInformation(tok, TokenUser, tu, len, &len) && EqualSid(owner, tu->User.Sid)) + ok = 1; + } + if (!ok) { + len = 0; + GetTokenInformation(tok, TokenOwner, NULL, 0, &len); + if (len != 0) { + to = (TOKEN_OWNER *)sd_alloc(len); + if (to != NULL && GetTokenInformation(tok, TokenOwner, to, len, &len) && to->Owner != NULL && + EqualSid(owner, to->Owner)) + ok = 1; + } + } +done: + if (tok != NULL) + CloseHandle(tok); + if (tu != NULL) + sd_free(tu); + if (to != NULL) + sd_free(to); + if (sd != NULL) + LocalFree(sd); + return ok; +} + +static int cache_path(wchar_t *path, size_t cch) +{ + static const wchar_t tail[] = L"\\LibreStatic\\ShellDrive\\state\\menu.cache"; + wchar_t base[MAX_PATH]; + size_t n; + +#ifdef SD_SHELLEXT_TEST + { + DWORD got = GetEnvironmentVariableW(L"SHELLDRIVE_TEST_MENU_CACHE", path, (DWORD)cch); + if (got != 0) + return got < cch; + } +#endif + if (FAILED(SHGetFolderPathW(NULL, CSIDL_LOCAL_APPDATA, NULL, 0 /* SHGFP_TYPE_CURRENT */, base))) + return 0; + n = wcslen(base); + if (n == 0 || n + wcslen(tail) + 1 > cch) + return 0; + memcpy(path, base, n * sizeof(wchar_t)); + memcpy(path + n, tail, (wcslen(tail) + 1) * sizeof(wchar_t)); + return 1; +} + +size_t shx_menucache_load(shx_profile *out, size_t max) +{ + wchar_t path[MAX_PATH + 64]; + HANDLE h; + LARGE_INTEGER size; + unsigned char *buf; + DWORD got = 0; + size_t count = 0; + + if (out == NULL || max == 0 || !cache_path(path, SD_COUNTOF(path))) + return 0; + h = CreateFileW(path, GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, NULL, OPEN_EXISTING, + FILE_ATTRIBUTE_NORMAL, NULL); + if (h == INVALID_HANDLE_VALUE) + return 0; + if (GetFileType(h) != FILE_TYPE_DISK || !GetFileSizeEx(h, &size) || size.QuadPart <= 0 || + size.QuadPart > (LONGLONG)SHX_CACHE_MAX_BYTES || !cache_owner_ok(h)) { + CloseHandle(h); + return 0; + } + buf = (unsigned char *)sd_alloc((size_t)size.QuadPart); + if (buf != NULL && ReadFile(h, buf, (DWORD)size.QuadPart, &got, NULL) && got == (DWORD)size.QuadPart) + count = shx_menucache_parse(buf, got, out, max); + if (buf != NULL) + sd_free(buf); + CloseHandle(h); + return count; +} diff --git a/apps/shelldrive-shellext/shellext_com.c b/apps/shelldrive-shellext/shellext_com.c new file mode 100644 index 0000000..74b3fcd --- /dev/null +++ b/apps/shelldrive-shellext/shellext_com.c @@ -0,0 +1,806 @@ +/* shelldrive-shellext.dll: COM server, class factories, the context menu handlers (03 §8.2). + * + * One object implements IShellExtInit and IContextMenu. Each interface is a subobject with its own vtable + * (the usual C pattern), so QueryInterface returns different pointers for the two interfaces and both + * recover the object with CONTAINING_RECORD. Two classes share the object: + * + * SHX_CLSID_CONTEXT_MENU Drive, Directory and * registrations. The selection is one path; the state section + * says whether it is on a ShellDrive drive (8.2.1). Items: + * drive root Open, Unmount, Reconnect, Copy remote path (by state and flags) + * inside a drive Copy remote path + * SHX_CLSID_COMPUTER_MENU This PC. Items: "Mount with ShellDrive" submenu (profiles from menu.cache, 8.2.2) + * and "Open ShellDrive". + * + * Menu planning, remote path formatting and the menu.cache parser are pure functions in shellext_model.c. + * InvokeCommand starts shelldrive-ui.exe --action and returns; it never waits. Copy remote path + * puts the text on the clipboard in process (no UI process, no wait beyond a bounded clipboard retry). */ +#define INITGUID +#include +#include +#include +#include +#include +#include +#include + +#include "sd_base/sd_mem.h" +#include "sd_l10n/sd_l10n.h" +#include "sd_l10n_shell_ids.h" +#include "shellext_int.h" +#include "shellext_snapshot.h" + +#ifndef GCS_UNICODE +#define GCS_UNICODE 0x00000004 +#endif +#ifndef CMIC_MASK_UNICODE +#define CMIC_MASK_UNICODE 0x00004000 +#endif + +/* Profiles shown + "More connections" + "Open ShellDrive" (computer), or at most four drive items. */ +#define SX_MAX_ACTIONS (SHX_SUBMENU_MAX + 2u) +#define SX_PATH_UNITS (3u + SHX_REL_MAX + 1u) + +typedef struct sx_action { + shx_act act; + wchar_t arg[SHX_ID_UNITS + 1]; /* profile id for SHX_ACT_MOUNT_PROFILE */ +} sx_action; + +typedef struct SxObject { + IShellExtInit init; /* first subobject: IShellExtInit (and IUnknown through it) */ + IContextMenu menu; /* second subobject: IContextMenu */ + LONG refs; + int computer; /* 1: the This PC class */ + wchar_t letter; /* drive letter of the one selected path, 0 if none */ + int is_root; /* the selected path is the drive root */ + wchar_t rel[SHX_REL_MAX + 1]; /* the path below the drive root */ + wchar_t copy[SHX_COPY_MAX]; /* text for Copy remote path, built by QueryContextMenu */ + unsigned action_count; /* command ids used by the last QueryContextMenu */ + sx_action actions[SX_MAX_ACTIONS]; +} SxObject; + +typedef struct SxFactory { + IClassFactory iface; + int computer; +} SxFactory; + +HINSTANCE shx_module = NULL; + +static volatile LONG g_objects = 0; /* live objects (DllCanUnloadNow) */ +static volatile LONG g_locks = 0; /* LockServer count */ +static volatile LONG g_l10n_state = 0; /* 0 not started, 1 starting, 2 ready */ + +/* ---- localization (03 §9.3): initialized once per process, on first use ---- */ + +static void sx_l10n_ready(void) +{ + unsigned spins; + if (InterlockedCompareExchange(&g_l10n_state, 1, 0) == 0) { + sd_l10n_init(&sd_l10n_catalog_shell, NULL); + InterlockedExchange(&g_l10n_state, 2); + return; + } + /* Another thread is initializing: wait briefly (the work is a few registry reads). */ + for (spins = 0; InterlockedCompareExchange(&g_l10n_state, 2, 2) != 2 && spins < 1000; spins++) + Sleep(1); +} + +static const wchar_t *sx_menu_text(shx_act a) +{ + switch (a) { + case SHX_ACT_UNMOUNT: return sd_l10n_get(SD_MSG_SHELL_MENU_UNMOUNT); + case SHX_ACT_RECONNECT: return sd_l10n_get(SD_MSG_SHELL_MENU_RECONNECT); + case SHX_ACT_COPY_PATH: return sd_l10n_get(SD_MSG_SHELL_MENU_COPY_REMOTE_PATH); + case SHX_ACT_SHOW: return sd_l10n_get(SD_MSG_SHELL_MENU_OPEN_SHELLDRIVE); + default: return sd_l10n_get(SD_MSG_SHELL_MENU_OPEN); + } +} + +static const wchar_t *sx_help_text(shx_act a) +{ + switch (a) { + case SHX_ACT_UNMOUNT: return sd_l10n_get(SD_MSG_SHELL_HELP_UNMOUNT); + case SHX_ACT_RECONNECT: return sd_l10n_get(SD_MSG_SHELL_HELP_RECONNECT); + case SHX_ACT_COPY_PATH: return sd_l10n_get(SD_MSG_SHELL_HELP_COPY_REMOTE_PATH); + case SHX_ACT_MOUNT_PROFILE: return sd_l10n_get(SD_MSG_SHELL_HELP_MOUNT_PROFILE); + case SHX_ACT_SHOW: return sd_l10n_get(SD_MSG_SHELL_HELP_OPEN_SHELLDRIVE); + case SHX_ACT_NONE: return sd_l10n_get(SD_MSG_SHELL_HELP_MOUNT_SUBMENU); + default: return sd_l10n_get(SD_MSG_SHELL_HELP_OPEN); + } +} + +/* Machine-readable names: GCS_VERB and string verbs in InvokeCommand. Not shown to the user. Returns 0 when the + action has none. */ +static int sx_verb_name(const sx_action *a, wchar_t *out, size_t cch) +{ + const wchar_t *base; + switch (a->act) { + case SHX_ACT_UNMOUNT: base = L"shelldrive.unmount"; break; + case SHX_ACT_RECONNECT: base = L"shelldrive.reconnect"; break; + case SHX_ACT_COPY_PATH: base = L"shelldrive.copyremotepath"; break; + case SHX_ACT_SHOW: base = L"shelldrive.show"; break; + case SHX_ACT_OPEN: base = L"shelldrive.open"; break; + case SHX_ACT_MOUNT_PROFILE: base = L"shelldrive.mount."; break; + default: return 0; + } + if (wcslen(base) + (a->act == SHX_ACT_MOUNT_PROFILE ? wcslen(a->arg) : 0) + 1 > cch) + return 0; + wcscpy_s(out, cch, base); + if (a->act == SHX_ACT_MOUNT_PROFILE) + wcscat_s(out, cch, a->arg); + return 1; +} + +/* ---- object lifetime and IUnknown ---- */ + +static HRESULT sx_qi(SxObject *o, REFIID riid, void **ppv) +{ + if (ppv == NULL) + return E_POINTER; + *ppv = NULL; + if (IsEqualIID(riid, &IID_IUnknown) || IsEqualIID(riid, &IID_IShellExtInit)) + *ppv = &o->init; + else if (IsEqualIID(riid, &IID_IContextMenu)) + *ppv = &o->menu; + else + return E_NOINTERFACE; + InterlockedIncrement(&o->refs); + return S_OK; +} + +static ULONG sx_release(SxObject *o) +{ + LONG r = InterlockedDecrement(&o->refs); + if (r == 0) { + sd_free(o); + InterlockedDecrement(&g_objects); + } + return (ULONG)(r < 0 ? 0 : r); +} + +/* ---- IShellExtInit ---- */ + +static HRESULT STDMETHODCALLTYPE init_QueryInterface(IShellExtInit *This, REFIID riid, void **ppv) +{ + return sx_qi(CONTAINING_RECORD(This, SxObject, init), riid, ppv); +} + +static ULONG STDMETHODCALLTYPE init_AddRef(IShellExtInit *This) +{ + SxObject *o = CONTAINING_RECORD(This, SxObject, init); + return (ULONG)InterlockedIncrement(&o->refs); +} + +static ULONG STDMETHODCALLTYPE init_Release(IShellExtInit *This) +{ + return sx_release(CONTAINING_RECORD(This, SxObject, init)); +} + +static int sx_is_letter(wchar_t c) +{ + return (c >= L'A' && c <= L'Z') || (c >= L'a' && c <= L'z'); +} + +/* Reads the selected path (CF_HDROP). Exactly one path counts, and only "X:\" or "X:\something": a UNC path or + a second selected item gives no items. */ +static HRESULT STDMETHODCALLTYPE init_Initialize(IShellExtInit *This, LPCITEMIDLIST pidlFolder, + IDataObject *pdtobj, HKEY hkeyProgID) +{ + SxObject *o = CONTAINING_RECORD(This, SxObject, init); + FORMATETC fe; + STGMEDIUM sm; + HDROP drop; + UINT count, len; + wchar_t path[SX_PATH_UNITS + 1]; + HRESULT hr; + + (void)pidlFolder; + (void)hkeyProgID; + o->letter = 0; + o->is_root = 0; + o->rel[0] = 0; + sx_l10n_ready(); + if (o->computer) + return S_OK; /* the class says what is meant; the data object is not needed */ + if (pdtobj == NULL) + return E_INVALIDARG; + + memset(&fe, 0, sizeof(fe)); + fe.cfFormat = CF_HDROP; + fe.dwAspect = DVASPECT_CONTENT; + fe.lindex = -1; + fe.tymed = TYMED_HGLOBAL; + memset(&sm, 0, sizeof(sm)); + hr = pdtobj->lpVtbl->GetData(pdtobj, &fe, &sm); + if (FAILED(hr)) + return hr; + if (sm.tymed != TYMED_HGLOBAL || sm.hGlobal == NULL) { + ReleaseStgMedium(&sm); + return E_INVALIDARG; + } + drop = (HDROP)sm.hGlobal; + count = DragQueryFileW(drop, 0xFFFFFFFFu, NULL, 0); + if (count == 1) { + len = DragQueryFileW(drop, 0, NULL, 0); + if (len >= 3 && len < SX_PATH_UNITS) { + UINT got = DragQueryFileW(drop, 0, path, SX_PATH_UNITS); + if (got == len && sx_is_letter(path[0]) && path[1] == L':' && path[2] == L'\\') { + wchar_t letter = (wchar_t)((path[0] >= L'a' && path[0] <= L'z') ? path[0] - (L'a' - L'A') : path[0]); + if (len == 3) { + o->letter = letter; + o->is_root = 1; + } else if (path[3] != L'\\') { + o->letter = letter; + wcscpy_s(o->rel, SD_COUNTOF(o->rel), path + 3); + } + } + } + } + ReleaseStgMedium(&sm); + return S_OK; +} + +/* ---- IContextMenu ---- */ + +static HRESULT STDMETHODCALLTYPE menu_QueryInterface(IContextMenu *This, REFIID riid, void **ppv) +{ + return sx_qi(CONTAINING_RECORD(This, SxObject, menu), riid, ppv); +} + +static ULONG STDMETHODCALLTYPE menu_AddRef(IContextMenu *This) +{ + SxObject *o = CONTAINING_RECORD(This, SxObject, menu); + return (ULONG)InterlockedIncrement(&o->refs); +} + +static ULONG STDMETHODCALLTYPE menu_Release(IContextMenu *This) +{ + return sx_release(CONTAINING_RECORD(This, SxObject, menu)); +} + +static void sx_add_action(SxObject *o, shx_act a, const wchar_t *arg) +{ + if (o->action_count < SX_MAX_ACTIONS) { + sx_action *s = &o->actions[o->action_count++]; + s->act = a; + s->arg[0] = 0; + if (arg != NULL) + wcsncpy_s(s->arg, SD_COUNTOF(s->arg), arg, _TRUNCATE); + } +} + +/* Only the faults a mapped read can raise are caught (access violation, in-page error); anything else keeps + searching for a handler, as usual. */ +static int sx_fault_filter(unsigned long code) +{ + return (code == EXCEPTION_ACCESS_VIOLATION || code == EXCEPTION_IN_PAGE_ERROR) ? EXCEPTION_EXECUTE_HANDLER + : EXCEPTION_CONTINUE_SEARCH; +} + +/* Decides the items of a path from the snapshot. Runs under __try: a parser fault must not reach Explorer. */ +static void sx_build_path_actions(SxObject *o, shx_entry *ents) +{ + size_t n, i; + + o->action_count = 0; + o->copy[0] = 0; + __try { + n = shx_snapshot_read(ents, SHX_SNAP_MAX_ENTRIES); + for (i = 0; i < n; i++) { + const shx_entry *e = &ents[i]; + shx_act plan[4]; + size_t k, pn; + int has_url; + if ((wchar_t)e->letter != o->letter) + continue; + has_url = (e->flags & SHX_F_HAS_REMOTE_URL) != 0 && shx_remote_url_ok(e->remote_url) && + shx_remote_copy_text(e->remote_url, o->rel, o->copy, SD_COUNTOF(o->copy)); + pn = shx_plan_drive(e->flags, e->state, o->is_root, has_url, plan, SD_COUNTOF(plan)); + for (k = 0; k < pn; k++) + sx_add_action(o, plan[k], NULL); + break; /* one entry per drive letter */ + } + } __except (sx_fault_filter(GetExceptionCode())) { + o->action_count = 0; + o->copy[0] = 0; + } +} + +/* Loads the profiles of menu.cache. Runs under __try like the snapshot read. */ +static size_t sx_load_profiles(shx_profile *p, size_t max) +{ + size_t n = 0; + __try { + n = shx_menucache_load(p, max); + } __except (sx_fault_filter(GetExceptionCode())) { + n = 0; + } + return n; +} + +static int sx_insert_string(HMENU hmenu, UINT pos, UINT id, const wchar_t *text, HMENU sub, int disabled) +{ + MENUITEMINFOW mii; + memset(&mii, 0, sizeof(mii)); + mii.cbSize = sizeof(mii); + mii.fMask = MIIM_STRING | MIIM_FTYPE | MIIM_STATE; + mii.fType = MFT_STRING; + mii.fState = disabled ? MFS_DISABLED : MFS_ENABLED; + if (sub != NULL) { + mii.fMask |= MIIM_SUBMENU; + mii.hSubMenu = sub; + } else { + mii.fMask |= MIIM_ID; + mii.wID = id; + } + mii.dwTypeData = (LPWSTR)text; + mii.cch = (UINT)wcslen(text); + return InsertMenuItemW(hmenu, pos, TRUE, &mii) ? 1 : 0; +} + +/* Path classes: the items go straight into the menu, one command id each. */ +static HRESULT sx_query_path(SxObject *o, HMENU hmenu, UINT indexMenu, UINT idCmdFirst, UINT idCmdLast) +{ + shx_entry *ents; + unsigned k; + UINT added = 0; + + if (o->letter == 0) + return MAKE_HRESULT(SEVERITY_SUCCESS, 0, 0); + ents = (shx_entry *)sd_alloc(sizeof(shx_entry) * SHX_SNAP_MAX_ENTRIES); + if (ents == NULL) + return MAKE_HRESULT(SEVERITY_SUCCESS, 0, 0); + sx_build_path_actions(o, ents); + sd_free(ents); + + for (k = 0; k < o->action_count; k++) { + if (idCmdFirst + k > idCmdLast) + break; + if (!sx_insert_string(hmenu, indexMenu + added, idCmdFirst + k, sx_menu_text(o->actions[k].act), NULL, 0)) + break; + added++; + } + o->action_count = added; + return MAKE_HRESULT(SEVERITY_SUCCESS, 0, added); +} + +/* This PC: the submenu with the profiles, then "Open ShellDrive". Command ids are the profile items, the + optional "More connections..." item and "Open ShellDrive", in that order. */ +static HRESULT sx_query_computer(SxObject *o, HMENU hmenu, UINT indexMenu, UINT idCmdFirst, UINT idCmdLast) +{ + shx_profile *profiles; + size_t n, shown, i; + HMENU sub; + UINT room = idCmdLast >= idCmdFirst ? idCmdLast - idCmdFirst + 1 : 0; + UINT pos = 0; + + if (room < 2) + return MAKE_HRESULT(SEVERITY_SUCCESS, 0, 0); + profiles = (shx_profile *)sd_alloc(sizeof(shx_profile) * SHX_CACHE_MAX_ENTRIES); + if (profiles == NULL) + return MAKE_HRESULT(SEVERITY_SUCCESS, 0, 0); + n = sx_load_profiles(profiles, SHX_CACHE_MAX_ENTRIES); + shown = n > SHX_SUBMENU_MAX ? SHX_SUBMENU_MAX : n; + if (shown + 2 > room) + shown = room - 2; + + o->action_count = 0; + sub = CreatePopupMenu(); + if (sub == NULL) { + sd_free(profiles); + return MAKE_HRESULT(SEVERITY_SUCCESS, 0, 0); + } + if (n == 0) { + sx_add_action(o, SHX_ACT_NONE, NULL); + (void)sx_insert_string(sub, pos++, idCmdFirst, sd_l10n_get(SD_MSG_SHELL_MENU_NO_PROFILES), NULL, 1); + } + for (i = 0; i < shown; i++) { + wchar_t label[2 * SHX_LABEL_MAX + 8]; + shx_menu_label(profiles[i].name, label, SD_COUNTOF(label)); + if (label[0] == 0) + wcscpy_s(label, SD_COUNTOF(label), L"?"); + sx_add_action(o, SHX_ACT_MOUNT_PROFILE, profiles[i].id); + (void)sx_insert_string(sub, pos++, idCmdFirst + (UINT)o->action_count - 1, label, NULL, 0); + } + if (n > shown) { + sx_add_action(o, SHX_ACT_SHOW, NULL); + (void)sx_insert_string(sub, pos++, idCmdFirst + (UINT)o->action_count - 1, + sd_l10n_get(SD_MSG_SHELL_MENU_MORE_PROFILES), NULL, 0); + } + sd_free(profiles); + + if (!sx_insert_string(hmenu, indexMenu, 0, sd_l10n_get(SD_MSG_SHELL_MENU_MOUNT_SUBMENU), sub, 0)) { + DestroyMenu(sub); + o->action_count = 0; + return MAKE_HRESULT(SEVERITY_SUCCESS, 0, 0); + } + sx_add_action(o, SHX_ACT_SHOW, NULL); + if (!sx_insert_string(hmenu, indexMenu + 1, idCmdFirst + (UINT)o->action_count - 1, + sd_l10n_get(SD_MSG_SHELL_MENU_OPEN_SHELLDRIVE), NULL, 0)) + o->action_count--; + return MAKE_HRESULT(SEVERITY_SUCCESS, 0, o->action_count); +} + +static HRESULT STDMETHODCALLTYPE menu_QueryContextMenu(IContextMenu *This, HMENU hmenu, UINT indexMenu, + UINT idCmdFirst, UINT idCmdLast, UINT uFlags) +{ + SxObject *o = CONTAINING_RECORD(This, SxObject, menu); + + o->action_count = 0; + if ((uFlags & CMF_DEFAULTONLY) != 0 || hmenu == NULL) + return MAKE_HRESULT(SEVERITY_SUCCESS, 0, 0); + return o->computer ? sx_query_computer(o, hmenu, indexMenu, idCmdFirst, idCmdLast) + : sx_query_path(o, hmenu, indexMenu, idCmdFirst, idCmdLast); +} + +/* Case-insensitive ASCII match of a string verb against the machine names of the current items. Returns the + index of the item or -1. */ +static int sx_find_verb(const SxObject *o, const char *s) +{ + unsigned k; + for (k = 0; k < o->action_count; k++) { + wchar_t name[80]; + size_t i; + int same = 1; + if (!sx_verb_name(&o->actions[k], name, SD_COUNTOF(name))) + continue; + for (i = 0; name[i] != 0; i++) { + char c = s[i]; + if (c >= 'A' && c <= 'Z') + c = (char)(c + ('a' - 'A')); + if (c == 0 || (wchar_t)(unsigned char)c != name[i]) { + same = 0; + break; + } + } + if (same && s[i] == 0) + return (int)k; + } + return -1; +} + +#ifdef SD_SHELLEXT_TEST +/* Test hook (test builds only): write the text to the file named by the environment variable instead of + starting the UI or touching the clipboard. */ +static int sx_test_write(const wchar_t *env_name, const wchar_t *text) +{ + wchar_t file[MAX_PATH]; + HANDLE h; + DWORD written = 0, bytes; + BOOL ok; + if (GetEnvironmentVariableW(env_name, file, MAX_PATH) == 0) + return 0; + h = CreateFileW(file, GENERIC_WRITE, 0, NULL, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL); + if (h == INVALID_HANDLE_VALUE) + return 0; + bytes = (DWORD)(wcslen(text) * sizeof(wchar_t)); + ok = WriteFile(h, text, bytes, &written, NULL) && written == bytes; + CloseHandle(h); + return ok ? 1 : 0; +} +#endif + +/* Starts shelldrive-ui.exe from this DLL's directory. Returns when the process is created. arg may be NULL. */ +static int sx_launch(const wchar_t *verb, const wchar_t *arg) +{ + wchar_t dir[1024]; + wchar_t exe[1100]; + wchar_t cmd[1400]; + DWORD n; + wchar_t *slash; + STARTUPINFOW si; + PROCESS_INFORMATION pi; + + n = GetModuleFileNameW(shx_module, dir, SD_COUNTOF(dir)); + if (n == 0 || n >= SD_COUNTOF(dir)) + return 0; + slash = wcsrchr(dir, L'\\'); + if (slash == NULL) + return 0; + slash[1] = 0; /* keep the trailing backslash */ + if (_snwprintf_s(exe, SD_COUNTOF(exe), _TRUNCATE, L"%s%s", dir, SHX_UI_EXE) < 0) + return 0; + exe[SD_COUNTOF(exe) - 1] = 0; + if (arg != NULL) { + if (_snwprintf_s(cmd, SD_COUNTOF(cmd), _TRUNCATE, L"\"%s\" --action %s %s", exe, verb, arg) < 0) + return 0; + } else { + if (_snwprintf_s(cmd, SD_COUNTOF(cmd), _TRUNCATE, L"\"%s\" --action %s", exe, verb) < 0) + return 0; + } + cmd[SD_COUNTOF(cmd) - 1] = 0; + +#ifdef SD_SHELLEXT_TEST + if (GetEnvironmentVariableW(L"SHELLDRIVE_TEST_SHELLEXT_CMDLINE_FILE", NULL, 0) != 0) + return sx_test_write(L"SHELLDRIVE_TEST_SHELLEXT_CMDLINE_FILE", cmd); +#endif + + memset(&si, 0, sizeof(si)); + memset(&pi, 0, sizeof(pi)); + si.cb = sizeof(si); + if (!CreateProcessW(exe, cmd, NULL, NULL, FALSE, 0, NULL, dir, &si, &pi)) + return 0; + CloseHandle(pi.hThread); + CloseHandle(pi.hProcess); + return 1; +} + +/* Puts text on the clipboard as CF_UNICODETEXT. The clipboard is retried for at most about 50 ms: Explorer's + thread must not wait for another program. */ +static int sx_copy_to_clipboard(HWND hwnd, const wchar_t *text) +{ + size_t bytes = (wcslen(text) + 1) * sizeof(wchar_t); + HGLOBAL g; + void *p; + unsigned tries; + int opened = 0; + +#ifdef SD_SHELLEXT_TEST + if (GetEnvironmentVariableW(L"SHELLDRIVE_TEST_SHELLEXT_CLIP_FILE", NULL, 0) != 0) + return sx_test_write(L"SHELLDRIVE_TEST_SHELLEXT_CLIP_FILE", text); +#endif + g = GlobalAlloc(GMEM_MOVEABLE, bytes); + if (g == NULL) + return 0; + p = GlobalLock(g); + if (p == NULL) { + GlobalFree(g); + return 0; + } + memcpy(p, text, bytes); + GlobalUnlock(g); + for (tries = 0; tries < 5; tries++) { + if (OpenClipboard(hwnd)) { + opened = 1; + break; + } + Sleep(10); + } + if (!opened) { + GlobalFree(g); + return 0; + } + EmptyClipboard(); + if (SetClipboardData(CF_UNICODETEXT, g) == NULL) { + CloseClipboard(); + GlobalFree(g); + return 0; + } + CloseClipboard(); /* the clipboard owns g now */ + return 1; +} + +static int sx_run(SxObject *o, const sx_action *a, HWND hwnd) +{ + wchar_t drive[3]; + switch (a->act) { + case SHX_ACT_COPY_PATH: + return o->copy[0] != 0 && sx_copy_to_clipboard(hwnd, o->copy); + case SHX_ACT_MOUNT_PROFILE: + return sx_launch(L"mount", a->arg); + case SHX_ACT_SHOW: + return sx_launch(L"show", NULL); + case SHX_ACT_OPEN: + case SHX_ACT_UNMOUNT: + case SHX_ACT_RECONNECT: + if (o->letter == 0) + return 0; + drive[0] = o->letter; + drive[1] = L':'; + drive[2] = 0; + return sx_launch(a->act == SHX_ACT_UNMOUNT ? L"unmount" : a->act == SHX_ACT_RECONNECT ? L"reconnect" : L"open", + drive); + default: + return 0; + } +} + +static HRESULT STDMETHODCALLTYPE menu_InvokeCommand(IContextMenu *This, LPCMINVOKECOMMANDINFO pici) +{ + SxObject *o = CONTAINING_RECORD(This, SxObject, menu); + unsigned idx; + + if (pici == NULL || pici->cbSize < sizeof(CMINVOKECOMMANDINFO)) + return E_INVALIDARG; + if (((ULONG_PTR)pici->lpVerb >> 16) == 0) { + /* MAKEINTRESOURCE: the offset of the item from idCmdFirst. */ + idx = (unsigned)((ULONG_PTR)pici->lpVerb & 0xFFFFu); + if (idx >= o->action_count || idx >= SX_MAX_ACTIONS) + return E_FAIL; + } else { + /* Verb string (ANSI): accepted only when it names one of our items. */ + int k; + if ((pici->fMask & CMIC_MASK_UNICODE) != 0) + return E_FAIL; + k = sx_find_verb(o, pici->lpVerb); + if (k < 0) + return E_FAIL; + idx = (unsigned)k; + } + return sx_run(o, &o->actions[idx], pici->hwnd) ? S_OK : E_FAIL; +} + +static HRESULT STDMETHODCALLTYPE menu_GetCommandString(IContextMenu *This, UINT_PTR idCmd, UINT uType, + UINT *pwReserved, LPSTR pszName, UINT cchMax) +{ + SxObject *o = CONTAINING_RECORD(This, SxObject, menu); + UINT type = uType & ~((UINT)GCS_UNICODE); + int unicode = (uType & GCS_UNICODE) != 0; + const wchar_t *text; + wchar_t verb[80]; + + (void)pwReserved; + if (idCmd >= o->action_count || idCmd >= SX_MAX_ACTIONS) + return E_INVALIDARG; + if (type == GCS_VALIDATEA) + return S_OK; + if (pszName == NULL || cchMax == 0) + return E_INVALIDARG; + if (type == GCS_VERBA) { + if (!sx_verb_name(&o->actions[idCmd], verb, SD_COUNTOF(verb))) + return E_FAIL; + text = verb; + } else if (type == GCS_HELPTEXTA) { + text = sx_help_text(o->actions[idCmd].act); + } else { + return E_NOTIMPL; + } + + if (unicode) { + size_t len = wcslen(text); + wchar_t *dst = (wchar_t *)pszName; + if (len >= cchMax) { + len = cchMax - 1; + } + memcpy(dst, text, len * sizeof(wchar_t)); + dst[len] = 0; + return S_OK; + } + if (WideCharToMultiByte(CP_ACP, 0, text, -1, pszName, (int)cchMax, NULL, NULL) == 0) { + pszName[0] = 0; + return E_FAIL; + } + return S_OK; +} + +/* ---- vtables ---- */ + +static IShellExtInitVtbl g_init_vtbl = { + init_QueryInterface, + init_AddRef, + init_Release, + init_Initialize +}; + +static IContextMenuVtbl g_menu_vtbl = { + menu_QueryInterface, + menu_AddRef, + menu_Release, + menu_QueryContextMenu, + menu_InvokeCommand, + menu_GetCommandString +}; + +/* ---- class factories (static: no allocation, no reference count of its own) ---- */ + +static HRESULT sx_create(int computer, REFIID riid, void **ppv); + +static HRESULT STDMETHODCALLTYPE fac_QueryInterface(IClassFactory *This, REFIID riid, void **ppv) +{ + if (ppv == NULL) + return E_POINTER; + *ppv = NULL; + if (IsEqualIID(riid, &IID_IUnknown) || IsEqualIID(riid, &IID_IClassFactory)) { + *ppv = This; + return S_OK; + } + return E_NOINTERFACE; +} + +static ULONG STDMETHODCALLTYPE fac_AddRef(IClassFactory *This) +{ + (void)This; + return 1; +} + +static ULONG STDMETHODCALLTYPE fac_Release(IClassFactory *This) +{ + (void)This; + return 1; +} + +static HRESULT STDMETHODCALLTYPE fac_CreateInstance(IClassFactory *This, IUnknown *pUnkOuter, REFIID riid, + void **ppv) +{ + if (ppv == NULL) + return E_POINTER; + *ppv = NULL; + if (pUnkOuter != NULL) + return CLASS_E_NOAGGREGATION; + return sx_create(CONTAINING_RECORD(This, SxFactory, iface)->computer, riid, ppv); +} + + +static HRESULT STDMETHODCALLTYPE fac_LockServer(IClassFactory *This, BOOL fLock) +{ + (void)This; + if (fLock) + InterlockedIncrement(&g_locks); + else + InterlockedDecrement(&g_locks); + return S_OK; +} + +static IClassFactoryVtbl g_factory_vtbl = { + fac_QueryInterface, + fac_AddRef, + fac_Release, + fac_CreateInstance, + fac_LockServer +}; + +static SxFactory g_factory_path = { { &g_factory_vtbl }, 0 }; +static SxFactory g_factory_computer = { { &g_factory_vtbl }, 1 }; + +/* Creates one object with its two vtables set, and returns the interface asked for (reference count 1). */ +static HRESULT sx_create(int computer, REFIID riid, void **ppv) +{ + SxObject *o; + HRESULT hr; + o = (SxObject *)sd_calloc(1, sizeof(SxObject)); + if (o == NULL) + return E_OUTOFMEMORY; + o->init.lpVtbl = &g_init_vtbl; + o->menu.lpVtbl = &g_menu_vtbl; + o->computer = computer; + InterlockedIncrement(&g_objects); + hr = sx_qi(o, riid, ppv); + if (FAILED(hr)) { + sd_free(o); + InterlockedDecrement(&g_objects); + } + return hr; +} + +/* ---- exports (shelldrive-shellext.def) ---- */ + +BOOL WINAPI DllMain(HINSTANCE inst, DWORD reason, LPVOID reserved) +{ + (void)reserved; + if (reason == DLL_PROCESS_ATTACH) { + shx_module = inst; + DisableThreadLibraryCalls(inst); + } + return TRUE; +} + +_Check_return_ +STDAPI DllGetClassObject(_In_ REFCLSID rclsid, _In_ REFIID riid, _Outptr_ LPVOID *ppv) +{ + SxFactory *f; + if (ppv == NULL) + return E_POINTER; + *ppv = NULL; + if (IsEqualCLSID(rclsid, &SHX_CLSID_CONTEXT_MENU)) + f = &g_factory_path; + else if (IsEqualCLSID(rclsid, &SHX_CLSID_COMPUTER_MENU)) + f = &g_factory_computer; + else + return CLASS_E_CLASSNOTAVAILABLE; + return f->iface.lpVtbl->QueryInterface(&f->iface, riid, ppv); +} + +__control_entrypoint(DllExport) +STDAPI DllCanUnloadNow(void) +{ + return (InterlockedCompareExchange(&g_objects, 0, 0) == 0 && InterlockedCompareExchange(&g_locks, 0, 0) == 0) ? S_OK : S_FALSE; +} + +STDAPI DllRegisterServer(void) +{ + return shx_register_server(); +} + +STDAPI DllUnregisterServer(void) +{ + return shx_unregister_server(); +} diff --git a/apps/shelldrive-shellext/shellext_int.h b/apps/shelldrive-shellext/shellext_int.h new file mode 100644 index 0000000..4b8aaac --- /dev/null +++ b/apps/shelldrive-shellext/shellext_int.h @@ -0,0 +1,38 @@ +/* shelldrive-shellext.dll internals (03 §1.6, §8.2, §8.5). Shared by the COM code and the registration code. + * + * The DLL is loaded into Explorer and into every process that shows a file dialog. Keep it small: no + * network, no IPC pipe, no profile files, no threads, and no global constructors. */ +#ifndef SD_SHELLEXT_INT_H +#define SD_SHELLEXT_INT_H + +#include + +#include "shellext_model.h" + +/* Class id of the context menu handler. Fixed for the life of the product: changing it orphans every + installed registration. Defined (storage) in shellext_com.c, which is built with INITGUID. */ +DEFINE_GUID(SHX_CLSID_CONTEXT_MENU, 0xc2a44993, 0x862b, 0x42ab, 0xb4, 0x0b, 0x25, 0x4e, 0xfb, 0x1f, 0x5c, 0x43); + +/* Class id of the This PC handler ("Mount with ShellDrive" submenu, "Open ShellDrive", 03 §8.2). A class of its + own, so Explorer's registration says which menu is meant and the code never has to guess from the data object. */ +DEFINE_GUID(SHX_CLSID_COMPUTER_MENU, 0x5e1c7d02, 0x3b8f, 0x4a9e, 0x9c, 0x61, 0x7d, 0x24, 0xa0, 0xf3, 0xb5, 0xe8); + +/* Text of the class id in registry form (keep in step with the GUID above). */ +#define SHX_CLSID_TEXT L"{C2A44993-862B-42AB-B40B-254EFB1F5C43}" +#define SHX_CLSID_COMPUTER_TEXT L"{5E1C7D02-3B8F-4A9E-9C61-7D24A0F3B5E8}" +#define SHX_HANDLER_NAME L"ShellDrive" +#define SHX_DESCRIPTION L"ShellDrive context menu" +#define SHX_DESCRIPTION_COMPUTER L"ShellDrive This PC context menu" +#define SHX_UI_EXE L"shelldrive-ui.exe" + +/* Handle of this DLL, set by DllMain. */ +extern HINSTANCE shx_module; + +/* menu.cache (03 §8.2.2): the profiles of the This PC submenu, at most max. Returns 0 for no data. shellext_cache.c. */ +size_t shx_menucache_load(shx_profile *out, size_t max); + +/* Per-user registration (HKCU\Software\Classes and the Approved list). shellext_register.c. */ +HRESULT shx_register_server(void); +HRESULT shx_unregister_server(void); + +#endif /* SD_SHELLEXT_INT_H */ diff --git a/apps/shelldrive-shellext/shellext_model.c b/apps/shelldrive-shellext/shellext_model.c new file mode 100644 index 0000000..e78a003 --- /dev/null +++ b/apps/shelldrive-shellext/shellext_model.c @@ -0,0 +1,386 @@ +/* Pure helpers of the shell extension. See shellext_model.h. */ +#include + +#include "shellext_model.h" +#include "shellext_snapshot.h" + +/* ---- characters ---- */ + +/* Bidi controls and zero-width characters (03 §2.5): removed from anything shown, rejected in a URL. */ +static int mdl_invisible(unsigned c) +{ + return (c >= 0x200Bu && c <= 0x200Fu) || (c >= 0x202Au && c <= 0x202Eu) || (c >= 0x2060u && c <= 0x2064u) || + (c >= 0x2066u && c <= 0x2069u) || c == 0x061Cu || c == 0xFEFFu || c == 0x00ADu; +} + +static int mdl_control(unsigned c) +{ + return c < 0x20u || (c >= 0x7Fu && c <= 0x9Fu); +} + +/* ---- menu planning ---- */ + +size_t shx_plan_drive(uint32_t flags, uint32_t state, int is_root, int has_url, shx_act *out, size_t max) +{ + size_t n = 0; + if (out == NULL || max == 0) + return 0; + if (is_root) { + if (n < max) + out[n++] = SHX_ACT_OPEN; + if ((flags & SHX_F_GLOBAL) == 0) { + if (state != SHX_STATE_UNMOUNTING && state != SHX_STATE_UNMOUNTED && n < max) + out[n++] = SHX_ACT_UNMOUNT; + if ((state == SHX_STATE_OFFLINE || state == SHX_STATE_AUTH_REQUIRED || state == SHX_STATE_FAILED) && n < max) + out[n++] = SHX_ACT_RECONNECT; + } + } + if (has_url && n < max) + out[n++] = SHX_ACT_COPY_PATH; + return n; +} + +/* ---- remote path ---- */ + +int shx_remote_url_ok(const wchar_t *url) +{ + size_t i, n; + if (url == NULL) + return 0; + n = wcsnlen(url, SHX_URL_UNITS + 1); + if (n <= 7 || n > SHX_URL_UNITS) + return 0; + if (wcsncmp(url, L"sftp://", 7) != 0) + return 0; + for (i = 7; i < n; i++) { + unsigned c = (unsigned)(unsigned short)url[i]; + if (mdl_control(c) || mdl_invisible(c)) + return 0; + } + return url[7] != L'/'; /* an authority is required */ +} + +typedef struct mdl_out { + wchar_t *p; + size_t cch; + size_t n; + int ok; +} mdl_out; + +static void mdl_put(mdl_out *o, wchar_t c) +{ + if (!o->ok) + return; + if (o->n + 1 >= o->cch) { + o->ok = 0; + return; + } + o->p[o->n++] = c; +} + +static void mdl_put_pct(mdl_out *o, unsigned b) +{ + static const wchar_t hex[] = L"0123456789ABCDEF"; + mdl_put(o, L'%'); + mdl_put(o, hex[(b >> 4) & 15u]); + mdl_put(o, hex[b & 15u]); +} + +/* RFC 3986 unreserved and the sub-delims, ':' and '@' that a path segment may carry as they are. */ +static int mdl_pchar(unsigned c) +{ + if ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9')) + return 1; + switch (c) { + case '-': case '.': case '_': case '~': case '!': case '$': case '&': case '\'': case '(': case ')': + case '*': case '+': case ',': case ';': case '=': case ':': case '@': + return 1; + default: + return 0; + } +} + +/* One code point at s[*i]; advances *i. Returns 0xFFFFFFFF for a lone surrogate. */ +static uint32_t mdl_next_cp(const wchar_t *s, size_t *i) +{ + uint32_t c = (uint32_t)(unsigned short)s[*i]; + (*i)++; + if (c >= 0xD800u && c <= 0xDBFFu) { + uint32_t d = (uint32_t)(unsigned short)s[*i]; + if (d >= 0xDC00u && d <= 0xDFFFu) { + (*i)++; + return 0x10000u + ((c - 0xD800u) << 10) + (d - 0xDC00u); + } + return 0xFFFFFFFFu; + } + if (c >= 0xDC00u && c <= 0xDFFFu) + return 0xFFFFFFFFu; + return c; +} + +static void mdl_put_cp(mdl_out *o, uint32_t cp) +{ + if (cp < 0x80u) { + if (mdl_pchar(cp)) + mdl_put(o, (wchar_t)cp); + else + mdl_put_pct(o, cp); + } else if (cp < 0x800u) { + mdl_put_pct(o, 0xC0u | (cp >> 6)); + mdl_put_pct(o, 0x80u | (cp & 0x3Fu)); + } else if (cp < 0x10000u) { + mdl_put_pct(o, 0xE0u | (cp >> 12)); + mdl_put_pct(o, 0x80u | ((cp >> 6) & 0x3Fu)); + mdl_put_pct(o, 0x80u | (cp & 0x3Fu)); + } else { + mdl_put_pct(o, 0xF0u | (cp >> 18)); + mdl_put_pct(o, 0x80u | ((cp >> 12) & 0x3Fu)); + mdl_put_pct(o, 0x80u | ((cp >> 6) & 0x3Fu)); + mdl_put_pct(o, 0x80u | (cp & 0x3Fu)); + } +} + +int shx_remote_copy_text(const wchar_t *url, const wchar_t *rel, wchar_t *out, size_t cch) +{ + mdl_out o; + size_t base, i, rn; + + if (out == NULL || cch == 0) + return 0; + out[0] = 0; + if (rel == NULL || !shx_remote_url_ok(url)) + return 0; + rn = wcsnlen(rel, SHX_REL_MAX + 1); + if (rn > SHX_REL_MAX) + return 0; + + o.p = out; + o.cch = cch; + o.n = 0; + o.ok = 1; + + if (rn == 0) { + /* The drive root itself: the URL as the agent wrote it. */ + for (i = 0; url[i] != 0; i++) + mdl_put(&o, url[i]); + } else { + base = wcslen(url); + while (base > 7 && url[base - 1] == L'/') + base--; + for (i = 0; i < base; i++) + mdl_put(&o, url[i]); + i = 0; + while (i < rn) { + size_t start = i, end, k; + while (i < rn && rel[i] != L'\\' && rel[i] != L'/') + i++; + end = i; + if (i < rn) + i++; /* the separator */ + if (end == start) + continue; /* empty component */ + if ((end - start == 1 && rel[start] == L'.') || + (end - start == 2 && rel[start] == L'.' && rel[start + 1] == L'.')) { + out[0] = 0; + return 0; + } + mdl_put(&o, L'/'); + k = start; + while (k < end) { + uint32_t cp = mdl_next_cp(rel, &k); + if (cp == 0xFFFFFFFFu) { + out[0] = 0; + return 0; + } + mdl_put_cp(&o, cp); + } + } + } + if (!o.ok) { + out[0] = 0; + return 0; + } + out[o.n] = 0; + return 1; +} + +/* ---- menu.cache ---- */ + +/* Strict UTF-8 -> UTF-16 of [s, s+n). Returns the number of units written (<= cap) or -1 on invalid input, + overlong forms, surrogates, code points above U+10FFFF, control characters, or output above cap. */ +static int mdl_utf8_to_utf16(const unsigned char *s, size_t n, wchar_t *out, size_t cap) +{ + size_t i = 0, o = 0; + while (i < n) { + uint32_t cp; + unsigned char b = s[i]; + size_t extra, k; + if (b < 0x80u) { + cp = b; + extra = 0; + } else if (b >= 0xC2u && b <= 0xDFu) { + cp = b & 0x1Fu; + extra = 1; + } else if (b >= 0xE0u && b <= 0xEFu) { + cp = b & 0x0Fu; + extra = 2; + } else if (b >= 0xF0u && b <= 0xF4u) { + cp = b & 0x07u; + extra = 3; + } else { + return -1; + } + for (k = 1; k <= extra; k++) { + if (i + k >= n || (s[i + k] & 0xC0u) != 0x80u) + return -1; + cp = (cp << 6) | (s[i + k] & 0x3Fu); + } + if ((extra == 2 && cp < 0x800u) || (extra == 3 && cp < 0x10000u) || cp > 0x10FFFFu || + (cp >= 0xD800u && cp <= 0xDFFFu) || mdl_control(cp)) + return -1; + i += extra + 1; + if (cp >= 0x10000u) { + if (o + 2 > cap) + return -1; + cp -= 0x10000u; + out[o++] = (wchar_t)(0xD800u + (cp >> 10)); + out[o++] = (wchar_t)(0xDC00u + (cp & 0x3FFu)); + } else { + if (o + 1 > cap) + return -1; + out[o++] = (wchar_t)cp; + } + } + return (int)o; +} + +static int mdl_hex_lower(unsigned char c) +{ + return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f'); +} + +size_t shx_menucache_parse(const unsigned char *buf, size_t len, shx_profile *out, size_t max) +{ + size_t pos = 0, count = 0, lines = 0; + int header = 0; + + if (buf == NULL || out == NULL || max == 0 || len == 0 || len > SHX_CACHE_MAX_BYTES) + return 0; + if (len >= 3 && buf[0] == 0xEF && buf[1] == 0xBB && buf[2] == 0xBF) + pos = 3; + while (pos < len) { + size_t start = pos, end, k; + while (pos < len && buf[pos] != '\n') + pos++; + end = pos; + if (pos < len) + pos++; + if (end > start && buf[end - 1] == '\r') + end--; + if (++lines > 4u * SHX_CACHE_MAX_ENTRIES) + break; /* bounded work whatever the content */ + + if (!header) { + static const unsigned char tag[] = "SDMENU 1"; + if (end - start != sizeof(tag) - 1 || memcmp(buf + start, tag, sizeof(tag) - 1) != 0) + return 0; + header = 1; + continue; + } + if (end == start || buf[start] == '#') + continue; + if (count >= max || count >= SHX_CACHE_MAX_ENTRIES) + break; + if (end - start < SHX_ID_UNITS + 2 || buf[start + SHX_ID_UNITS] != '\t') + continue; + { + shx_profile p; + int units, dup = 0; + for (k = 0; k < SHX_ID_UNITS; k++) { + if (!mdl_hex_lower(buf[start + k])) + break; + p.id[k] = (wchar_t)buf[start + k]; + } + if (k != SHX_ID_UNITS) + continue; + p.id[SHX_ID_UNITS] = 0; + units = mdl_utf8_to_utf16(buf + start + SHX_ID_UNITS + 1, end - start - SHX_ID_UNITS - 1, p.name, + SHX_NAME_UNITS); + if (units <= 0) + continue; + p.name[units] = 0; + for (k = 0; k < count; k++) { + if (wcscmp(out[k].id, p.id) == 0) { + dup = 1; + break; + } + } + if (dup) + continue; + out[count++] = p; + } + } + return header ? count : 0; +} + +/* ---- menu label ---- */ + +void shx_menu_label(const wchar_t *name, wchar_t *out, size_t cch) +{ + size_t i = 0, o = 0, shown = 0; + int cut = 0; + + if (out == NULL || cch == 0) + return; + out[0] = 0; + if (name == NULL) + return; + while (name[i] != 0) { + unsigned c = (unsigned)(unsigned short)name[i]; + size_t units = 1; + if (c >= 0xD800u && c <= 0xDBFFu) { + unsigned d = (unsigned)(unsigned short)name[i + 1]; + if (d < 0xDC00u || d > 0xDFFFu) { + i++; /* lone high surrogate: dropped */ + continue; + } + units = 2; + } else if (c >= 0xDC00u && c <= 0xDFFFu) { + i++; /* lone low surrogate: dropped */ + continue; + } + if (units == 1 && mdl_invisible(c)) { + i++; + continue; + } + if (shown >= SHX_LABEL_MAX) { + cut = 1; + break; + } + if (units == 1 && mdl_control(c)) + c = ' '; + if (c == '&') { + if (o + 2 >= cch) + break; + out[o++] = L'&'; + out[o++] = L'&'; + } else { + if (o + units >= cch) + break; + if (units == 2) { + out[o++] = name[i]; + out[o++] = name[i + 1]; + } else { + out[o++] = (wchar_t)c; + } + } + shown++; + i += units; + } + if (cut && o + 3 < cch) { + out[o++] = L'.'; + out[o++] = L'.'; + out[o++] = L'.'; + } + out[o] = 0; +} diff --git a/apps/shelldrive-shellext/shellext_model.h b/apps/shelldrive-shellext/shellext_model.h new file mode 100644 index 0000000..d4318f0 --- /dev/null +++ b/apps/shelldrive-shellext/shellext_model.h @@ -0,0 +1,64 @@ +/* Pure helpers of the shell extension (03 §8.2): menu planning, remote path formatting, the menu.cache parser and + * menu label sanitizing. No Win32 calls, no allocation, no global state: every function is reentrant and is + * unit-tested directly. Inputs that come from other processes (the state section, menu.cache) are untrusted. */ +#ifndef SD_SHELLEXT_MODEL_H +#define SD_SHELLEXT_MODEL_H + +#include +#include +#include + +#define SHX_URL_UNITS 320u /* remote_url slot of the state section */ +#define SHX_ID_UNITS 32u +#define SHX_NAME_UNITS 128u +#define SHX_REL_MAX 2047u /* longest path below a drive root that Copy remote path handles */ +#define SHX_COPY_MAX 4096u /* longest text put on the clipboard (with the terminating NUL) */ +#define SHX_CACHE_MAX_BYTES 65536u /* menu.cache size cap (03 §8.2) */ +#define SHX_CACHE_MAX_ENTRIES 256u +#define SHX_SUBMENU_MAX 32u /* profiles listed in the This PC submenu; the rest sit behind "More" */ +#define SHX_LABEL_MAX 48u /* visible characters of a profile name in the menu */ + +/* What a menu item does. */ +typedef enum shx_act { + SHX_ACT_NONE = 0, /* disabled placeholder, never invoked */ + SHX_ACT_OPEN, /* --action open X: */ + SHX_ACT_UNMOUNT, /* --action unmount X: */ + SHX_ACT_RECONNECT, /* --action reconnect X: */ + SHX_ACT_COPY_PATH, /* remote path to the clipboard (in process) */ + SHX_ACT_MOUNT_PROFILE, /* --action mount */ + SHX_ACT_SHOW /* --action show */ +} shx_act; + +/* One profile of menu.cache. */ +typedef struct shx_profile { + wchar_t id[SHX_ID_UNITS + 1]; /* 32 lowercase hex digits */ + wchar_t name[SHX_NAME_UNITS + 1]; /* display name, valid UTF-16, no control characters */ +} shx_profile; + +/* Items for one selected path that is on a ShellDrive drive (03 §8.2): max items written, count returned. + is_root: the selection is the drive root. flags and state are those of the state entry. has_url: the entry + carries a usable remote URL. Drive roots: Open for every entry; Unmount and Reconnect for per-user entries + by state; Copy remote path when has_url. Items inside the drive: Copy remote path only, when has_url. */ +size_t shx_plan_drive(uint32_t flags, uint32_t state, int is_root, int has_url, shx_act *out, size_t max); + +/* 1 when url is a usable remote URL: starts with "sftp://", fits the slot, and has no control, bidi or + zero-width characters. Such a URL is copied as it is (the agent writes it ready for use, 03 §8.2.1). */ +int shx_remote_url_ok(const wchar_t *url); + +/* "sftp://user@host:port/base" + rel -> out. rel is the path below the drive root ("dir\file", no leading + backslash; "" for the root itself): the backslashes become '/', each component is percent-encoded as UTF-8 + (RFC 3986 pchar characters stay), empty components are skipped, and "." and ".." refuse the whole call. + The base is used verbatim without its trailing slashes. Returns 1 on success; on failure out[0] = 0. */ +int shx_remote_copy_text(const wchar_t *url, const wchar_t *rel, wchar_t *out, size_t cch); + +/* Parses menu.cache v1 (UTF-8, optional BOM): first line "SDMENU 1", then one profile per line, + "<32 lowercase hex>". Blank lines and lines starting with '#' are skipped; a malformed line or a + duplicate id is dropped on its own. A wrong first line, or len above SHX_CACHE_MAX_BYTES, means no data. + At most max profiles (and SHX_CACHE_MAX_ENTRIES) are returned. */ +size_t shx_menucache_parse(const unsigned char *buf, size_t len, shx_profile *out, size_t max); + +/* Text for a menu item from a profile name: bidi and zero-width characters removed, control characters + become spaces, '&' doubled (no accidental mnemonic), cut at SHX_LABEL_MAX characters with "...". */ +void shx_menu_label(const wchar_t *name, wchar_t *out, size_t cch); + +#endif /* SD_SHELLEXT_MODEL_H */ diff --git a/apps/shelldrive-shellext/shellext_register.c b/apps/shelldrive-shellext/shellext_register.c new file mode 100644 index 0000000..512ecc2 --- /dev/null +++ b/apps/shelldrive-shellext/shellext_register.c @@ -0,0 +1,157 @@ +/* Per-user registration of the context menu handler (03 §8.5): the development path and the portable + * layout. Everything is written under HKEY_CURRENT_USER, so no administrator rights are needed. The installer + * writes the same keys under HKLM\Software\Classes (04), which this code does not touch. + * + * Keys (relative to HKCU): + * Software\Classes\CLSID\{clsid} default = name (path class and This PC class) + * Software\Classes\CLSID\{clsid}\InprocServer32 default = DLL path, ThreadingModel = Apartment + * Software\Classes\{Drive,Directory,*}\shellex\ContextMenuHandlers\ShellDrive default = {path clsid} + * Software\Classes\CLSID\{20D04FE0-...}\shellex\ContextMenuHandlers\ShellDrive default = {This PC clsid} + * Software\Microsoft\Windows\CurrentVersion\Shell Extensions\Approved value {clsid} = name (both) + * + * The Approved entry is needed where EnforceShellExtensionSecurity is set. Explorer's check for the per-user + * list is [verify on Windows 10 and 11]. Unregister removes the leaves it created and the parent keys only + * when they are empty (RegDeleteKeyW refuses non-empty keys), so other handlers are left alone. */ +#include +#include +#include + +#include "sd/base.h" +#include "shellext_int.h" + +#define SHX_KEY_CLASSES L"Software\\Classes" +#define SHX_KEY_CLSID SHX_KEY_CLASSES L"\\CLSID\\" SHX_CLSID_TEXT +#define SHX_KEY_INPROC SHX_KEY_CLSID L"\\InprocServer32" +#define SHX_KEY_CLSID_PC SHX_KEY_CLASSES L"\\CLSID\\" SHX_CLSID_COMPUTER_TEXT +#define SHX_KEY_INPROC_PC SHX_KEY_CLSID_PC L"\\InprocServer32" +#define SHX_CMH L"\\shellex\\ContextMenuHandlers" +#define SHX_KEY_APPROVED L"Software\\Microsoft\\Windows\\CurrentVersion\\Shell Extensions\\Approved" + +/* The path class is registered for Drive, Directory and * (all files); the This PC class under the Computer + folder's CLSID (03 §8.2). */ +#define SHX_KEY_PC_FOLDER SHX_KEY_CLASSES L"\\CLSID\\{20D04FE0-3AEA-1069-A2D8-08002B30309D}" + +typedef struct shx_hook { + const wchar_t *root; /* key under HKCU that owns the shellex subtree */ + const wchar_t *clsid; /* class id text written as the handler's default value */ +} shx_hook; + +static const shx_hook k_hooks[] = { + { SHX_KEY_CLASSES L"\\Drive", SHX_CLSID_TEXT }, + { SHX_KEY_CLASSES L"\\Directory", SHX_CLSID_TEXT }, + { SHX_KEY_CLASSES L"\\*", SHX_CLSID_TEXT }, + { SHX_KEY_PC_FOLDER, SHX_CLSID_COMPUTER_TEXT } +}; + +static LONG sx_set_string(const wchar_t *subkey, const wchar_t *value_name, const wchar_t *data) +{ + HKEY key = NULL; + LONG rc = RegCreateKeyExW(HKEY_CURRENT_USER, subkey, 0, NULL, REG_OPTION_NON_VOLATILE, KEY_WRITE, NULL, + &key, NULL); + if (rc != ERROR_SUCCESS) + return rc; + rc = RegSetValueExW(key, value_name, 0, REG_SZ, (const BYTE *)data, (DWORD)((wcslen(data) + 1) * sizeof(wchar_t))); + RegCloseKey(key); + return rc; +} + +static LONG sx_delete_key(const wchar_t *subkey) +{ + LONG rc = RegDeleteKeyW(HKEY_CURRENT_USER, subkey); + if (rc == ERROR_FILE_NOT_FOUND) + return ERROR_SUCCESS; + return rc; +} + +static LONG sx_delete_value(const wchar_t *subkey, const wchar_t *value_name) +{ + HKEY key = NULL; + LONG rc = RegOpenKeyExW(HKEY_CURRENT_USER, subkey, 0, KEY_SET_VALUE, &key); + if (rc == ERROR_FILE_NOT_FOUND) + return ERROR_SUCCESS; + if (rc != ERROR_SUCCESS) + return rc; + rc = RegDeleteValueW(key, value_name); + RegCloseKey(key); + if (rc == ERROR_FILE_NOT_FOUND) + return ERROR_SUCCESS; + return rc; +} + +static LONG sx_hook_key(const shx_hook *h, const wchar_t *tail, wchar_t *out, size_t cch) +{ + return _snwprintf_s(out, cch, _TRUNCATE, L"%s%s%s", h->root, SHX_CMH, tail) < 0 ? ERROR_INSUFFICIENT_BUFFER + : ERROR_SUCCESS; +} + +HRESULT shx_register_server(void) +{ + wchar_t path[1024]; + wchar_t key[512]; + DWORD n; + LONG rc; + size_t i; + + n = GetModuleFileNameW(shx_module, path, (DWORD)SD_COUNTOF(path)); + if (n == 0 || n >= SD_COUNTOF(path)) + return E_FAIL; + + rc = sx_set_string(SHX_KEY_CLSID, NULL, SHX_DESCRIPTION); + if (rc == ERROR_SUCCESS) + rc = sx_set_string(SHX_KEY_INPROC, NULL, path); + if (rc == ERROR_SUCCESS) + rc = sx_set_string(SHX_KEY_INPROC, L"ThreadingModel", L"Apartment"); + if (rc == ERROR_SUCCESS) + rc = sx_set_string(SHX_KEY_CLSID_PC, NULL, SHX_DESCRIPTION_COMPUTER); + if (rc == ERROR_SUCCESS) + rc = sx_set_string(SHX_KEY_INPROC_PC, NULL, path); + if (rc == ERROR_SUCCESS) + rc = sx_set_string(SHX_KEY_INPROC_PC, L"ThreadingModel", L"Apartment"); + for (i = 0; rc == ERROR_SUCCESS && i < SD_COUNTOF(k_hooks); i++) { + rc = sx_hook_key(&k_hooks[i], L"\\" SHX_HANDLER_NAME, key, SD_COUNTOF(key)); + if (rc == ERROR_SUCCESS) + rc = sx_set_string(key, NULL, k_hooks[i].clsid); + } + if (rc == ERROR_SUCCESS) + rc = sx_set_string(SHX_KEY_APPROVED, SHX_CLSID_TEXT, SHX_DESCRIPTION); + if (rc == ERROR_SUCCESS) + rc = sx_set_string(SHX_KEY_APPROVED, SHX_CLSID_COMPUTER_TEXT, SHX_DESCRIPTION_COMPUTER); + return rc == ERROR_SUCCESS ? S_OK : E_FAIL; +} + +HRESULT shx_unregister_server(void) +{ + wchar_t key[512]; + LONG rc; + size_t i; + + /* Our leaves: a failure here is reported. */ + rc = sx_delete_value(SHX_KEY_APPROVED, SHX_CLSID_TEXT); + if (rc == ERROR_SUCCESS) + rc = sx_delete_value(SHX_KEY_APPROVED, SHX_CLSID_COMPUTER_TEXT); + for (i = 0; rc == ERROR_SUCCESS && i < SD_COUNTOF(k_hooks); i++) { + rc = sx_hook_key(&k_hooks[i], L"\\" SHX_HANDLER_NAME, key, SD_COUNTOF(key)); + if (rc == ERROR_SUCCESS) + rc = sx_delete_key(key); + } + if (rc == ERROR_SUCCESS) + rc = sx_delete_key(SHX_KEY_INPROC); + if (rc == ERROR_SUCCESS) + rc = sx_delete_key(SHX_KEY_CLSID); + if (rc == ERROR_SUCCESS) + rc = sx_delete_key(SHX_KEY_INPROC_PC); + if (rc == ERROR_SUCCESS) + rc = sx_delete_key(SHX_KEY_CLSID_PC); + if (rc != ERROR_SUCCESS) + return E_FAIL; + + /* Shared parents: removed only when empty, so other handlers stay registered. Errors are ignored. */ + for (i = 0; i < SD_COUNTOF(k_hooks); i++) { + if (sx_hook_key(&k_hooks[i], L"", key, SD_COUNTOF(key)) == ERROR_SUCCESS) + (void)sx_delete_key(key); /* ...\ContextMenuHandlers */ + if (_snwprintf_s(key, SD_COUNTOF(key), _TRUNCATE, L"%s\\shellex", k_hooks[i].root) >= 0) + (void)sx_delete_key(key); + (void)sx_delete_key(k_hooks[i].root); + } + return S_OK; +} diff --git a/apps/shelldrive-shellext/shellext_snapshot.c b/apps/shelldrive-shellext/shellext_snapshot.c new file mode 100644 index 0000000..98c9bd1 --- /dev/null +++ b/apps/shelldrive-shellext/shellext_snapshot.c @@ -0,0 +1,302 @@ +/* Reader of the agent's state section, layout v1 (03 §8.2.1; TM-R26). See shellext_snapshot.h. + * + * Production path: sd_ipc_state_open checks the owner and the mandatory label on the handle (fail closed). + * Test builds (SD_SHELLEXT_TEST): SHELLDRIVE_TEST_STATE_NAME, when set, names a section created by the test + * under a test-only name; the same owner and label rules are applied by sx_owner_and_label_ok below. + * + * Everything after the copy works on the local buffer only: the mapped view is read exactly twice per try + * (the seq counter and the copy), so a concurrent writer can change the view but never the parsed data. */ +#include +#include +#include + +#include "sd_base/sd_err.h" +#include "sd_base/sd_mem.h" +#include "sd_ipc/sd_ipc.h" +#include "shellext_snapshot.h" + +#ifndef LABEL_SECURITY_INFORMATION +#define LABEL_SECURITY_INFORMATION 0x00000010L +#endif +#ifndef SECURITY_MANDATORY_MEDIUM_RID +#define SECURITY_MANDATORY_MEDIUM_RID 0x00002000L +#endif +#ifndef SYSTEM_MANDATORY_LABEL_ACE_TYPE +#define SYSTEM_MANDATORY_LABEL_ACE_TYPE 0x11 +#endif + +#define SHX_SEQ_OFFSET 20u +#define SHX_TRIES 3u +#define SHX_SLOT_MOUNT_ID 24u /* WCHAR[32] */ +#define SHX_SLOT_PROFILE 88u /* WCHAR[32] */ +#define SHX_SLOT_NAME 152u /* WCHAR[128] */ +#define SHX_SLOT_TARGET 408u /* WCHAR[260] */ +#define SHX_SLOT_LABEL 928u /* WCHAR[64] */ +#define SHX_SLOT_URL 1056u /* WCHAR[320] */ + +/* Layout of SYSTEM_MANDATORY_LABEL_ACE, declared here so the SDK version does not matter. */ +typedef struct shx_label_ace { + ACE_HEADER Header; + ACCESS_MASK Mask; + DWORD SidStart; +} shx_label_ace; + +static uint32_t rd16(const unsigned char *p) +{ + return (uint32_t)p[0] | ((uint32_t)p[1] << 8); +} + +static uint32_t rd32(const unsigned char *p) +{ + return (uint32_t)p[0] | ((uint32_t)p[1] << 8) | ((uint32_t)p[2] << 16) | ((uint32_t)p[3] << 24); +} + +/* Well-formed UTF-16 up to the first NUL of a fixed slot of `units` code units. A slot without NUL is + valid (the slot length is the string length, 03 §8.2.1). */ +static int sx_slot_utf16_ok(const unsigned char *p, uint32_t units) +{ + uint32_t i; + for (i = 0; i < units; i++) { + uint32_t c = rd16(p + 2u * i); + if (c == 0) + return 1; + if (c >= 0xDC00u && c <= 0xDFFFu) + return 0; + if (c >= 0xD800u && c <= 0xDBFFu) { + uint32_t d; + if (i + 1 >= units) + return 0; + d = rd16(p + 2u * (i + 1)); + if (d < 0xDC00u || d > 0xDFFFu) + return 0; + i++; + } + } + return 1; +} + +/* Empty, or exactly 32 hex digits (03 §8.2.1: profile_id). */ +static int sx_profile_id_ok(const unsigned char *p) +{ + uint32_t i; + if (rd16(p) == 0) + return 1; + for (i = 0; i < 32; i++) { + uint32_t c = rd16(p + 2u * i); + int hex = (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'); + if (!hex) + return 0; + } + return 1; +} + +static int sx_parse_entry(const unsigned char *e, shx_entry *o) +{ + uint32_t flags = rd32(e); + uint32_t state = rd32(e + 4); + uint32_t backend = rd32(e + 8); + uint32_t letter = rd16(e + 16); + + if ((flags & SHX_F_IN_USE) == 0) + return 0; + if (flags & ~SHX_F_KNOWN_MASK) + return 0; + if (letter != 0 && (letter < 'A' || letter > 'Z')) + return 0; + if (!sx_slot_utf16_ok(e + SHX_SLOT_MOUNT_ID, 32) || !sx_slot_utf16_ok(e + SHX_SLOT_NAME, 128) || + !sx_slot_utf16_ok(e + SHX_SLOT_TARGET, 260) || !sx_slot_utf16_ok(e + SHX_SLOT_LABEL, 64) || + !sx_slot_utf16_ok(e + SHX_SLOT_URL, 320)) + return 0; + if (!sx_profile_id_ok(e + SHX_SLOT_PROFILE)) + return 0; + + /* Unknown enumeration values are read as 0 (not a reason to drop the entry). */ + if (state > SHX_STATE_UNMOUNTED) + state = SHX_STATE_UNKNOWN; + if (backend > 2u) + backend = 0; + + o->flags = flags; + o->state = state; + o->backend = backend; + o->mount_num = rd32(e + 12); + o->letter = (WCHAR)letter; + /* The URL slot was validated as well-formed UTF-16 above; a full slot without NUL gets one here. */ + memcpy(o->remote_url, e + SHX_SLOT_URL, 320u * sizeof(WCHAR)); + o->remote_url[320] = 0; + if ((flags & SHX_F_HAS_REMOTE_URL) == 0) + o->remote_url[0] = 0; + return 1; +} + +/* Validates the whole local copy and copies the valid entries out. */ +static size_t sx_parse(const unsigned char *b, uint32_t total, shx_entry *out, size_t max) +{ + uint32_t hs, es, ec, i; + size_t count = 0; + + if (rd32(b) != SHX_SNAP_MAGIC) + return 0; + if (rd16(b + 4) != 1u && rd16(b + 4) != 2u) /* layout 2 only adds fields after the v1 tail */ + return 0; + hs = rd16(b + 6); + if (hs < SHX_SNAP_HEADER_BYTES || hs > total) + return 0; + if (rd32(b + 8) != total || total > SHX_SNAP_MAX_BYTES) + return 0; + es = rd32(b + 12); + if (es < SHX_SNAP_ENTRY_BYTES || es > 4096u || (es % 8u) != 0) + return 0; + ec = rd32(b + 16); + if (ec > SHX_SNAP_MAX_ENTRIES) + return 0; + /* ec <= 128 and es <= 4096 keep the product below 2^19, so this sum cannot wrap. */ + if (total != hs + ec * es) + return 0; + + for (i = 0; i < ec && count < max; i++) { + shx_entry ent; + if (sx_parse_entry(b + hs + i * es, &ent)) { + out[count] = ent; + count++; + } + } + return count; +} + +#ifdef SD_SHELLEXT_TEST +/* Owner must be the current user and the mandatory label absent or at least medium (TM-R26). */ +static int sx_owner_and_label_ok(HANDLE h) +{ + PSID owner = NULL; + PACL sacl = NULL; + PSECURITY_DESCRIPTOR sd = NULL; + HANDLE tok = NULL; + TOKEN_USER *tu = NULL; + DWORD len = 0; + WORD i; + int ok = 0; + + if (GetSecurityInfo(h, SE_KERNEL_OBJECT, OWNER_SECURITY_INFORMATION | LABEL_SECURITY_INFORMATION, &owner, + NULL, NULL, &sacl, &sd) != ERROR_SUCCESS) + return 0; + if (!OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &tok)) + goto done; + GetTokenInformation(tok, TokenUser, NULL, 0, &len); + if (len == 0) + goto done; + tu = (TOKEN_USER *)sd_alloc(len); + if (tu == NULL || !GetTokenInformation(tok, TokenUser, tu, len, &len)) + goto done; + if (owner == NULL || !EqualSid(owner, tu->User.Sid)) + goto done; + ok = 1; + if (sacl != NULL) { + for (i = 0; i < sacl->AceCount; i++) { + LPVOID ace = NULL; + if (!GetAce(sacl, i, &ace) || ace == NULL) { + ok = 0; + break; + } + if (((ACE_HEADER *)ace)->AceType == SYSTEM_MANDATORY_LABEL_ACE_TYPE) { + PSID ls = (PSID)&((shx_label_ace *)ace)->SidStart; + UCHAR n = *GetSidSubAuthorityCount(ls); + if (n == 0 || *GetSidSubAuthority(ls, (DWORD)(n - 1)) < SECURITY_MANDATORY_MEDIUM_RID) + ok = 0; + break; + } + } + } +done: + if (tok != NULL) + CloseHandle(tok); + if (tu != NULL) + sd_free(tu); + if (sd != NULL) + LocalFree(sd); + return ok; +} + +static HANDLE sx_open_test_section(void) +{ + wchar_t name[260]; + DWORD n = GetEnvironmentVariableW(L"SHELLDRIVE_TEST_STATE_NAME", name, 260); + HANDLE h; + if (n == 0 || n >= 260) + return NULL; + h = OpenFileMappingW(FILE_MAP_READ | READ_CONTROL, FALSE, name); + if (h == NULL) + return NULL; + if (!sx_owner_and_label_ok(h)) { + CloseHandle(h); + return NULL; + } + return h; +} +#endif + +static HANDLE sx_open_section(void) +{ + HANDLE h = NULL; +#ifdef SD_SHELLEXT_TEST + if (GetEnvironmentVariableW(L"SHELLDRIVE_TEST_STATE_NAME", NULL, 0) != 0) + return sx_open_test_section(); +#endif + if (sd_ipc_state_open(&h) != SD_OK) + return NULL; + return h; +} + +size_t shx_snapshot_read(shx_entry *out, size_t max) +{ + HANDLE h; + unsigned char *view; + unsigned char *copy = NULL; + unsigned char hdr[SHX_SNAP_HEADER_BYTES]; + uint32_t seq1 = 0, seq2 = 0, total = 0; + unsigned attempt; + size_t count = 0; + + if (out == NULL || max == 0) + return 0; + h = sx_open_section(); + if (h == NULL) + return 0; + view = (unsigned char *)MapViewOfFile(h, FILE_MAP_READ, 0, 0, SHX_SNAP_MAX_BYTES); + if (view == NULL) { + CloseHandle(h); + return 0; + } + + for (attempt = 0; attempt < SHX_TRIES; attempt++) { + seq1 = *(const volatile uint32_t *)(view + SHX_SEQ_OFFSET); + if (seq1 & 1u) + continue; /* a write is in progress */ + memcpy(hdr, view, SHX_SNAP_HEADER_BYTES); + MemoryBarrier(); + total = rd32(hdr + 8); + if (total >= SHX_SNAP_HEADER_BYTES && total <= SHX_SNAP_MAX_BYTES) { + copy = (unsigned char *)sd_alloc(total); + if (copy == NULL) + break; + memcpy(copy, view, total); + } + MemoryBarrier(); + seq2 = *(const volatile uint32_t *)(view + SHX_SEQ_OFFSET); + if (seq2 != seq1) { + if (copy != NULL) + sd_free(copy); + copy = NULL; + continue; /* the writer published while we copied */ + } + break; /* stable: either a copy to parse or a stable bad header */ + } + UnmapViewOfFile(view); + CloseHandle(h); + + if (copy != NULL) { + count = sx_parse(copy, total, out, max); + sd_free(copy); + } + return count; +} diff --git a/apps/shelldrive-shellext/shellext_snapshot.h b/apps/shelldrive-shellext/shellext_snapshot.h new file mode 100644 index 0000000..9cde7b4 --- /dev/null +++ b/apps/shelldrive-shellext/shellext_snapshot.h @@ -0,0 +1,58 @@ +/* Reader of the agent's state section, layout v1 (03 §8.2.1, TM-R26). The shell extension sees the + section only through this interface, so the reader can later be replaced by sd_state without touching + the menu code. + * + * Contract: shx_snapshot_read copies the section, validates the copy and never reads the mapped view + * again after the copy. A missing section, a foreign owner or label (checked by sd_ipc_state_open), + * a bad header, or three unstable reads all mean "no data": the function returns 0 and the caller + * shows no drive items. Bad entries are dropped one by one. Nothing here touches the network, the IPC + * pipe or any profile file. */ +#ifndef SD_SHELLEXT_SNAPSHOT_H +#define SD_SHELLEXT_SNAPSHOT_H + +#include +#include +#include + +#define SHX_SNAP_MAGIC 0x54534453u /* "SDST" */ +#define SHX_SNAP_HEADER_BYTES 64u +#define SHX_SNAP_ENTRY_BYTES 1792u +#define SHX_SNAP_MAX_ENTRIES 128u +#define SHX_SNAP_MAX_BYTES 262144u /* section size, sd_ipc SD_IPC_STATE_BYTES */ + +/* Entry flags (bit 0 IN_USE; bits 7.. must be zero). */ +#define SHX_F_IN_USE 0x00000001u +#define SHX_F_GLOBAL 0x00000002u +#define SHX_F_READ_ONLY 0x00000004u +#define SHX_F_HAS_REMOTE_URL 0x00000008u +#define SHX_F_TRUNCATED 0x00000010u +#define SHX_F_NEEDS_ADMIN 0x00000020u +#define SHX_F_NETWORK_LOCATION 0x00000040u +#define SHX_F_KNOWN_MASK 0x0000007Fu + +/* Mount states (03 §8.2.1). Values above 8 are read as 0 (unknown). */ +#define SHX_STATE_UNKNOWN 0u +#define SHX_STATE_CONNECTING 1u +#define SHX_STATE_AUTHENTICATING 2u +#define SHX_STATE_MOUNTED 3u +#define SHX_STATE_OFFLINE 4u +#define SHX_STATE_AUTH_REQUIRED 5u +#define SHX_STATE_FAILED 6u +#define SHX_STATE_UNMOUNTING 7u +#define SHX_STATE_UNMOUNTED 8u + +/* One validated entry. Only the fields the menu needs are copied out. */ +typedef struct shx_entry { + uint32_t flags; + uint32_t state; + uint32_t backend; + uint32_t mount_num; + WCHAR letter; /* 'A'..'Z', or 0 for an entry without a drive letter */ + WCHAR remote_url[321]; /* sftp://user@host:port/path, NUL-terminated; empty without HAS_REMOTE_URL */ +} shx_entry; + +/* Copies up to max valid entries into out and returns how many were written (0 = no usable data). + Bounded: at most three tries of the seqlock read. */ +size_t shx_snapshot_read(shx_entry *out, size_t max); + +#endif /* SD_SHELLEXT_SNAPSHOT_H */ diff --git a/apps/shelldrive-ui/CMakeLists.txt b/apps/shelldrive-ui/CMakeLists.txt new file mode 100644 index 0000000..5e95913 --- /dev/null +++ b/apps/shelldrive-ui/CMakeLists.txt @@ -0,0 +1,107 @@ +# shelldrive-ui.exe: tray icon, main window (03 §1.4, §7), prompt dialogs (03 §2.5), the profile editor, quick +# mount and import, and the M3 windows: host key manager, Logs viewer, Settings and About (03 §5, §10, §3.2; +# ui_win.h). GUI subsystem, comctl32 v6 and DPI awareness come from sd_add_app WINDOWS (cmake/SdModule.cmake). +# +# ui_wire.c, ui_dpi.c, ui_dlg.c, ui_prompt_model.c, ui_sdmount.c, ui_profile_model.c, ui_logs_model.c, +# ui_hostkeys_model.c, ui_settings_model.c, ui_hkissue_model.c and ui_mainview_model.c are pure (no window, IPC or agent code): they are also compiled into +# the unit tests (tests/ui) through SD_UI_PURE_SOURCES. The profile editor (ui_editor.c) and the profile list +# (ui_profiles.c) link sd_config directly (03 §2.4 decision). + +set(SD_UI_PURE_SOURCES + ${CMAKE_CURRENT_SOURCE_DIR}/ui_wire.c + ${CMAKE_CURRENT_SOURCE_DIR}/ui_dpi.c + ${CMAKE_CURRENT_SOURCE_DIR}/ui_dlg.c + ${CMAKE_CURRENT_SOURCE_DIR}/ui_prompt_model.c + ${CMAKE_CURRENT_SOURCE_DIR}/ui_sdmount.c + ${CMAKE_CURRENT_SOURCE_DIR}/ui_profile_model.c + ${CMAKE_CURRENT_SOURCE_DIR}/ui_logs_model.c + ${CMAKE_CURRENT_SOURCE_DIR}/ui_hostkeys_model.c + ${CMAKE_CURRENT_SOURCE_DIR}/ui_settings_model.c + ${CMAKE_CURRENT_SOURCE_DIR}/ui_update_model.c + ${CMAKE_CURRENT_SOURCE_DIR}/ui_notify_model.c + ${CMAKE_CURRENT_SOURCE_DIR}/ui_dark_model.c + ${CMAKE_CURRENT_SOURCE_DIR}/ui_wizard_model.c + ${CMAKE_CURRENT_SOURCE_DIR}/ui_admin_model.c + ${CMAKE_CURRENT_SOURCE_DIR}/ui_service_model.c + ${CMAKE_CURRENT_SOURCE_DIR}/ui_hkissue_model.c + ${CMAKE_CURRENT_SOURCE_DIR}/ui_events_model.c + ${CMAKE_CURRENT_SOURCE_DIR}/ui_mainview_model.c + ${CMAKE_CURRENT_SOURCE_DIR}/ui_notice_model.c + ${CMAKE_CURRENT_SOURCE_DIR}/ui_evhist_model.c) +set(SD_UI_PURE_SOURCES ${SD_UI_PURE_SOURCES} PARENT_SCOPE) + +# Icon resources (res/icons): the template gets the absolute icon directory (rc.exe does not search the +# source tree for relative paths). +set(SD_UI_ICON_DIR "${CMAKE_SOURCE_DIR}/res/icons") +configure_file("${CMAKE_SOURCE_DIR}/res/icons/shelldrive-ui.rc.in" + "${CMAKE_CURRENT_BINARY_DIR}/shelldrive-ui-icons.rc" @ONLY) + +set(_svc_host_dir "${CMAKE_SOURCE_DIR}/apps/shelldrive-host") + +sd_add_app(shelldrive-ui + OUTPUT_NAME shelldrive-ui + DESCRIPTION "ShellDrive tray, main window, profile editor, prompts and the M3 windows" + WINDOWS + SOURCES + main.c + ui_about.c + ui_editor.c + ui_hostkeys.c + ui_events.c + ui_hkissue.c + ui_icon.c + ui_import.c + ui_ipc.c + ui_logs.c + ui_main.c + ui_measure.c + ui_model.c + ui_profiles.c + ui_prompt.c + ui_service.c + ui_secdlg.c + ui_settings.c + ui_svc.c + ui_update.c + ui_tray.c + ui_win.c + ui_dark.c + ui_wizard.c + ui_int.h + ui_res.h + ui_dark.h + ui_wizard.h + ui_notify_model.h + ui_dark_model.h + ui_wizard_model.h + ui_win.h + ui_wire.h + ui_dpi.h + ui_dlg.h + ui_measure.h + ui_prompt_model.h + ui_profile_model.h + ui_sdmount.h + ui_hostkeys_model.h + ui_logs_model.h + ui_settings_model.h + ui_update.h + ui_update_model.h + ui_admin_model.h + ui_service_model.h + ui_hkissue_model.h + ui_mainview_model.h + ui_events_model.h + ${CMAKE_CURRENT_BINARY_DIR}/shelldrive-ui-icons.rc + # the Service page (03 §1.3): the SCM status and the data folder repair are the code of the CLI verbs + # `service status` and `service repair-data` (shared with shelldrive.exe and the service host) + ${_svc_host_dir}/svc_scm.c + ${_svc_host_dir}/svc_scm.h + ${_svc_host_dir}/svc_repair.c + ${_svc_host_dir}/svc_repair.h + ${_svc_host_dir}/svc_folder.c + ${_svc_host_dir}/svc_folder.h + ${SD_UI_PURE_SOURCES} + DEPS sd_ipc sd_config sd_crypto sd_ssh sd_webdav sd_policy sd_l10n sd_l10n_catalog_ui sd_platform sd_base comctl32 comdlg32 shell32 + advapi32) +target_include_directories(shelldrive-ui PRIVATE "${_svc_host_dir}") diff --git a/apps/shelldrive-ui/main.c b/apps/shelldrive-ui/main.c new file mode 100644 index 0000000..11e2c74 --- /dev/null +++ b/apps/shelldrive-ui/main.c @@ -0,0 +1,920 @@ +/* shelldrive-ui.exe: tray icon and main window of ShellDrive (03 §1.4, §2, §7). + * + * One instance per (user, session): the named mutex SD_IPC_OBJ_UI_MUTEX. A second launch forwards its + * command line as a versioned payload (ui_wire.c) with WM_COPYDATA to the first instance's message-only + * window and exits. The receiving window accepts the message only from a process whose token user SID is + * ours and whose integrity level is not lower than ours (TM-R26, T-48); everything else is dropped. + * + * Modes: no arguments opens the main window; --tray starts with the window hidden (Run key); --prompt-host + * and --action are the verbs of ui_wire.c; --elevated-admin (03 §2.6 rule 3) answers the service + * prompts over the service pipe with no tray and no agent: it is relaunched by the tray attention item with UAC; + * --elevated-service shows only the Service page (ui_service.c) in an elevated process, relaunched by its + * "Repair data..." button, and exits when the page is closed; + * --test-window is the smoke test of tests/ui: it + * creates the main window hidden, checks the window class and its controls, and exits 0; --test-prompts + * creates every prompt dialog hidden and destroys it (exit 0 when all were created). + * + * --prompt-host (03 §1.4, §2.5 step 3): the agent starts this process when a prompt has nobody to answer it. + * It shows the tray icon and the prompt dialogs only, never starts an agent, leaves when the agent goes away + * and after 60 s with no open prompt. A prompt-host process that finds a running UI forwards and exits. */ +#include +#include +#include +#include +#include + +#include "sd_base/sd_err.h" +#include "sd_base/sd_mem.h" +#include "sd_ipc/sd_ipc.h" +#include "sd_l10n/sd_l10n.h" +#include "sd_l10n_ui_ids.h" +#include "sd_platform/sd_log.h" +#include "sd_platform/sd_platform.h" +#include "ui_dark.h" +#include "sd_platform/sd_time.h" +#include "ui_dpi.h" +#include "ui_evhist_model.h" +#include "ui_wizard.h" +#include "sd_config/sd_config.h" +#include "ui_admin_model.h" +#include "ui_int.h" +#include "ui_mainview_model.h" +#include "ui_sdmount.h" +#include "ui_win.h" + +#ifndef PROCESS_QUERY_LIMITED_INFORMATION +#define PROCESS_QUERY_LIMITED_INFORMATION 0x1000 +#endif + +ui_app g_app; + +static wchar_t g_sid[256]; + +/* ---- identities for the WM_COPYDATA sender check (TM-R26) ---- */ + +typedef struct ui_identity { + unsigned char *sid; /* copy of the token user SID */ + DWORD sid_len; + int has_integrity; /* Vista and later */ + DWORD integrity; /* mandatory label RID */ +} ui_identity; + +static ui_identity g_self; + +static int read_identity(HANDLE tok, ui_identity *id) +{ + DWORD need = 0; + TOKEN_USER *tu; + + memset(id, 0, sizeof(*id)); + GetTokenInformation(tok, TokenUser, NULL, 0, &need); + if (need == 0) + return 0; + tu = sd_alloc(need); + if (tu == NULL) + return 0; + if (!GetTokenInformation(tok, TokenUser, tu, need, &need)) { + sd_free(tu); + return 0; + } + id->sid_len = GetLengthSid(tu->User.Sid); + id->sid = sd_alloc(id->sid_len); + if (id->sid == NULL || !CopySid(id->sid_len, id->sid, tu->User.Sid)) { + sd_free(id->sid); + memset(id, 0, sizeof(*id)); + sd_free(tu); + return 0; + } + sd_free(tu); + + if (sd_os_at_least(6, 0, 0)) { + TOKEN_MANDATORY_LABEL *label; + need = 0; + GetTokenInformation(tok, TokenIntegrityLevel, NULL, 0, &need); + if (need != 0) { + label = sd_alloc(need); + if (label != NULL && GetTokenInformation(tok, TokenIntegrityLevel, label, need, &need)) { + UCHAR *count = GetSidSubAuthorityCount(label->Label.Sid); + if (count != NULL && *count > 0) { + id->integrity = *GetSidSubAuthority(label->Label.Sid, (DWORD)(*count - 1)); + id->has_integrity = 1; + } + } + sd_free(label); + } + } + return 1; +} + +static int read_self(void) +{ + HANDLE tok = NULL; + int ok = 0; + if (!OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &tok)) + return 0; + ok = read_identity(tok, &g_self); + CloseHandle(tok); + return ok; +} + +/* True when the window belongs to a process of this user with an integrity level not below ours. */ +static int sender_trusted(HWND sender) +{ + DWORD pid = 0; + HANDLE proc, tok; + ui_identity id; + int ok = 0; + + if (sender == NULL || !GetWindowThreadProcessId(sender, &pid) || pid == 0) + return 0; + proc = OpenProcess(sd_os_at_least(6, 0, 0) ? PROCESS_QUERY_LIMITED_INFORMATION : PROCESS_QUERY_INFORMATION, + FALSE, pid); + if (proc == NULL) + return 0; + if (OpenProcessToken(proc, TOKEN_QUERY, &tok)) { + if (read_identity(tok, &id)) { + ok = g_self.sid != NULL && id.sid != NULL && EqualSid(id.sid, g_self.sid); + if (ok && id.has_integrity && g_self.has_integrity && id.integrity < g_self.integrity) + ok = 0; + sd_free(id.sid); + } + CloseHandle(tok); + } + CloseHandle(proc); + return ok; +} + +/* ---- requests ---- */ + +/* --action mount-file (03 §8.1): the file must be a valid .sdmount naming one of the user's profiles. A file + with Mark of the Web asks first. Nothing else in the file is read, so it can only start a configured mount. */ +static void handle_mount_file(const wchar_t *path) +{ + unsigned char *buf; + size_t n = 0; + char id[33]; + sd_err e; + const sd_profile *p = NULL; + + buf = (unsigned char *)sd_alloc(UI_SDMOUNT_MAX_BYTES + 1u); + if (buf == NULL) + return; + e = ui_sdmount_read(path, buf, UI_SDMOUNT_MAX_BYTES + 1u, &n); + if (e == SD_OK) + e = ui_sdmount_parse(buf, n, id); + sd_free(buf); + if (e == SD_OK && ui_profiles_load() == SD_OK) + p = ui_profiles_find_id(id); + if (e != SD_OK || p == NULL) { + MessageBoxW(g_app.main, sd_l10n_get(SD_MSG_UI_MOUNT_FILE_INVALID), L"ShellDrive", MB_OK | MB_ICONWARNING); + return; + } + if (ui_file_has_mark(path)) { + wchar_t name[160]; + ui_text_utf8(p->name, name, SD_COUNTOF(name)); + if (!ui_confirm(g_app.main, SD_MSG_UI_MOUNT_FILE_TITLE, SD_MSG_UI_MOUNT_FILE_MARK, name, + SD_MSG_UI_MOUNT_FILE_CONNECT, SD_MSG_UI_MOUNT_FILE_CANCEL, 0)) + return; + } + ui_ipc_mount(id); +} + +void ui_app_handle_request(const ui_request *req) +{ + char id[40]; + ui_mount *m; + + switch (req->verb) { + case UI_VERB_NONE: + case UI_VERB_SHOW: + ui_main_show(); + break; + case UI_VERB_TRAY: + case UI_VERB_PROMPT_HOST: + case UI_VERB_ELEVATED_ADMIN: + /* --tray changes nothing here; --prompt-host and --elevated-admin were handled in wWinMain (g_app.prompt_host, + g_app.elevated_admin). */ + break; + case UI_VERB_ELEVATED_SERVICE: + ui_service_open(); + break; + case UI_VERB_UNMOUNT: + /* The drive must be one of this UI's mounts; the agent checks again. */ + m = ui_model_find_drive(&g_app.model, req->target); + if (m == NULL) { + SD_LOGW("ui", "unmount request for a drive that is not mounted: dropped"); + break; + } + ui_ipc_unmount(m->drive); + break; + case UI_VERB_MOUNT: + ui_text_ascii(req->target, id, sizeof(id)); + ui_ipc_mount(id); + break; + case UI_VERB_OPEN: + /* The shell extension's "Open" verb: the drive is one of ours (03 §8.2); Explorer shows it. */ + ui_open_drive(req->target); + break; + case UI_VERB_MOUNT_FILE: + handle_mount_file(req->target); + break; + case UI_VERB_RECONNECT: + /* The agent has no reconnect method yet (03 §2.4): the verb is accepted and logged only. */ + SD_LOGI("ui", "reconnect is not available in this build"); + break; + default: + break; + } +} + +void ui_app_on_agent_state(int up) +{ + g_app.agent_up = up ? 1 : 0; + ui_windows_on_agent_state(g_app.agent_up); + if (!up) { + /* the agent is gone: its prompts are gone with it (dialogs close without an answer) */ + ui_model_clear(&g_app.model); + ui_prompt_close_all(); + if (g_app.prompt_host) { + SD_LOGI("ui", "prompt host: the agent is gone, leaving"); + ui_app_quit(); + return; + } + } + ui_main_refresh(); + if (!up && g_app.prompt_host) + ui_app_quit(); /* a prompt host lives only as long as the agent that started it */ +} + +void ui_app_on_list(sd_json *result) +{ + ui_model_set_list(&g_app.model, result); + sd_json_free(result); + ui_main_refresh(); +} + +/* The event list (7.7) gets every change of state of a drive, and a failed mount with the error code. m is the mount + before the event is applied (NULL for one this UI has not listed). err_code: the error code of a failed state (NULL + when the event has no error object); reason: why the drive reconnected (mount.state `reason`) or NULL; when: unix + seconds for an entry of the agent's history, 0 for now. */ +static void record_state_entry(const ui_mount *m, const char *state, int has_err, const char *err_code, + const char *reason, long long when) +{ + wchar_t text[160]; + wchar_t subject[UI_EV_SUBJECT_MAX]; + wchar_t state_w[24]; + const wchar_t *state_text; + if (state == NULL || strlen(state) >= sizeof(m->state)) + return; + ui_text_utf8(state, state_w, SD_COUNTOF(state_w)); + if (m != NULL) { + ui_mount copy = *m; + strcpy_s(copy.state, sizeof(copy.state), state); + wcscpy_s(copy.state_w, SD_COUNTOF(copy.state_w), state_w); + state_text = ui_model_state_text(©); + wcsncpy_s(text, SD_COUNTOF(text), state_text, _TRUNCATE); + wcsncpy_s(subject, SD_COUNTOF(subject), m->drive[0] != 0 ? m->drive : m->server, _TRUNCATE); + } else { + wcsncpy_s(text, SD_COUNTOF(text), state_w, _TRUNCATE); + subject[0] = 0; + } + if (has_err) { + wchar_t wcode[64]; + wchar_t *t; + ui_text_utf8(err_code != NULL ? err_code : "error", wcode, SD_COUNTOF(wcode)); + t = ui_format1(SD_MSG_UI_EVENTS_TEXT_FAILED, "code", wcode); + if (t != NULL) { + ui_events_record_at(UI_EV_ERROR, when, subject, t); + sd_free(t); + } + return; + } + if (reason != NULL && reason[0] != 0) { + /* "Connecting (after: timeout)": the reason the connection comes back (03 2.4) */ + wchar_t wreason[160]; + wchar_t *t; + sd_arg a[2]; + ui_reason_text(reason, wreason, SD_COUNTOF(wreason)); + a[0].name = "state"; + a[0].type = SD_ARG_STR; + a[0].v.s = text; + a[1].name = "reason"; + a[1].type = SD_ARG_STR; + a[1].v.s = wreason; + t = sd_l10n_format_alloc(SD_MSG_UI_EVENTS_TEXT_REASON, a, 2); + if (t != NULL) { + ui_events_record_at(UI_EV_DRIVE, when, subject, t); + sd_free(t); + return; + } + } + ui_events_record_at(UI_EV_DRIVE, when, subject, text); +} + +static void record_mount_event(const ui_mount *m, const sd_json *data, const char *state) +{ + const sd_json *err = sd_json_get(data, "error"); + if (state == NULL) + return; + if (err == NULL && m != NULL && strcmp(m->state, state) == 0) + return; /* the same state again (connection details only) */ + record_state_entry(m, state, err != NULL, sd_json_get_str(err, "code"), sd_json_get_str(data, "reason"), 0); +} + +/* The name a notice carries: the drive letter, else the profile or the server; for a mount this UI has not listed, the + target of the event (target_obj is the event data, or the data of a warning). */ +static void notice_subject(const ui_mount *m, const sd_json *target_obj, wchar_t *out, size_t cch) +{ + out[0] = 0; + if (m != NULL) { + wcsncpy_s(out, cch, m->drive[0] != 0 ? m->drive : (m->profile[0] != 0 ? m->profile : m->server), _TRUNCATE); + return; + } + ui_text_utf8(sd_json_get_str(target_obj, "target"), out, cch); +} + +/* A mount.state change that leads to a notice of 7.7 (connection lost, reconnected, mounted, mount failed). */ +static void notice_mount_state(ui_nt_kind kind, const ui_mount *m, const sd_json *data) +{ + wchar_t subject[UI_EV_SUBJECT_MAX]; + wchar_t code[64]; + const char *c = sd_json_get_str(sd_json_get(data, "error"), "code"); + notice_subject(m, data, subject, SD_COUNTOF(subject)); + if (kind == UI_NT_RECONNECTED) { + /* why the connection was lost (mount.state `reason`, round 19): the notice says it when the agent does */ + c = sd_json_get_str(data, "reason"); + ui_text_utf8(c != NULL ? c : "", code, SD_COUNTOF(code)); + } else { + ui_text_utf8(c != NULL ? c : "error", code, SD_COUNTOF(code)); + } + ui_tray_notice(kind, subject, code, 0); +} + +/* A `warning` with the code proxy_cleartext, pool_limited, disk_cache_refused or hostkey_source_insecure. The proxy password notice is once per + (profile, proxy) for the life of the UI (7.7); the agent says it once per mount and hop. */ +static void notice_warning(ui_nt_kind kind, const ui_mount *m, const sd_json *ev_data) +{ + static ui_nt_once once; + const sd_json *d = sd_json_get(ev_data, "data"); + const char *detail = sd_json_get_str(d, "detail"); + wchar_t subject[UI_EV_SUBJECT_MAX]; + wchar_t wdetail[160]; + notice_subject(m, d, subject, SD_COUNTOF(subject)); + wdetail[0] = 0; + if (kind == UI_NT_PROXY_PASSWORD) { + char key[UI_NT_ONCE_KEY_MAX]; + const char *who = m != NULL && m->profile_id[0] != 0 ? m->profile_id : sd_json_get_str(ev_data, "mount_id"); + if (!ui_nt_once_key(who, detail, key, sizeof(key)) || !ui_nt_once_take(&once, key)) + return; + ui_text_utf8(detail != NULL ? detail : "", wdetail, SD_COUNTOF(wdetail)); + } + if (kind == UI_NT_HOSTKEYS_SOURCE) { + /* detail names the live source the agent ignores: openssh or putty (TM-R06); the notice says which */ + const char *name = ui_nt_source_name(detail); + ui_text_utf8(name != NULL ? name : "", wdetail, SD_COUNTOF(wdetail)); + } + ui_tray_notice(kind, subject, wdetail, 0); +} + +/* ---- the agent's event history (events.recent, 03 2.4, 7.7) ---- */ + +static ui_evh_state g_evh; +static int g_evh_ready; + +/* The name of the mount in the event list: the drive letter, else the profile or the server; empty for a mount this UI + does not know (the history has only its id). */ +static void history_subject(const char *mount_id, wchar_t *out, size_t cch) +{ + const ui_mount *m = ui_model_find_id(&g_app.model, mount_id); + out[0] = 0; + if (m != NULL) + wcsncpy_s(out, cch, m->drive[0] != 0 ? m->drive : (m->profile[0] != 0 ? m->profile : m->server), _TRUNCATE); +} + +/* One record of the history becomes the entry the live event would have made, at the time it happened. prev: the state + the mount had before (replayed from the history). */ +static void history_record(const ui_evh_rec *r, const char *prev) +{ + wchar_t subject[UI_EV_SUBJECT_MAX]; + history_subject(r->mount_id, subject, SD_COUNTOF(subject)); + if (strcmp(r->event, "mount.state") == 0) { + const ui_mount *m = ui_model_find_id(&g_app.model, r->mount_id); + int failed = strcmp(r->state, "failed") == 0; + ui_nt_kind nk = ui_nt_of_mount_state_r(prev, r->state, failed ? r->code : NULL, NULL); + if (nk != UI_NT_NONE) { + wchar_t code[64]; + ui_text_utf8(r->code[0] != 0 ? r->code : "error", code, SD_COUNTOF(code)); + ui_tray_history_notice(nk, r->time, subject, nk == UI_NT_MOUNT_FAILED ? code : L""); + } else if (failed || strcmp(prev, r->state) != 0) { + record_state_entry(m, r->state, failed, r->code, NULL, r->time); + } + } else if (strcmp(r->event, "warning") == 0) { + ui_nt_kind nk = ui_nt_of_warning_code(r->code); + if (nk == UI_NT_PROXY_PASSWORD) + return; /* the history does not say which proxy */ + if (nk != UI_NT_NONE) + ui_tray_history_notice(nk, r->time, subject, L""); + else + ui_tray_history_warning(r->code, r->time, subject); + } else if (strcmp(r->event, "hostkeys.announced") == 0) { + const ui_mount *m = ui_model_find_id(&g_app.model, r->mount_id); + if (m != NULL) + ui_tray_history_hostkeys(r->time, m->server); + } +} + +/* The reply of events.recent: fills the event list with what happened before this UI was running, skipping what it has + seen as live events already (ui_evhist_model.h). */ +void ui_app_on_history(sd_json *result) +{ + const sd_json *arr = sd_json_get(result, "events"); + int64_t last = 0; + size_t i, n = sd_json_count(arr); + ui_evh_track track; + if (!g_evh_ready) { + ui_evh_init(&g_evh); + g_evh_ready = 1; + } + if (!sd_json_get_int64(result, "last_seq", &last) || last < 0) + last = 0; + ui_evh_track_init(&track); + ui_evh_begin(&g_evh, (unsigned long long)last); + for (i = 0; i < n; i++) { + ui_evh_rec r; + char prev[24]; + if (!ui_evh_parse(sd_json_at(arr, i), &r)) + continue; + prev[0] = 0; + if (strcmp(r.event, "mount.state") == 0) + ui_evh_track_step(&track, r.mount_id, r.state, prev); + if (ui_evh_wanted(&g_evh, &r)) + history_record(&r, prev); + } + ui_evh_end(&g_evh, (unsigned long long)last); + sd_json_free(result); +} + +void ui_app_on_event(sd_json *ev) +{ + if (!g_evh_ready) { + ui_evh_init(&g_evh); + g_evh_ready = 1; + } + const char *name = sd_json_get_str(ev, "event"); + const sd_json *data = sd_json_get(ev, "data"); + + if (name != NULL && strcmp(name, "mount.state") == 0) { + const char *id = sd_json_get_str(data, "mount_id"); + const char *st = sd_json_get_str(data, "state"); + ui_mount *m = ui_model_find_id(&g_app.model, id); + ui_nt_kind nk = ui_nt_of_mount_state_r(m != NULL ? m->state : "", st, + sd_json_get_str(sd_json_get(data, "error"), "code"), + sd_json_get_str(data, "reason")); + ui_evh_note_live(&g_evh, name, id, st, sd_unix_time_now()); + /* a change the user is told about (7.7) is recorded by its notice, the others by the plain state entry */ + if (nk != UI_NT_NONE) + notice_mount_state(nk, m, data); + else + record_mount_event(m, data, st); + if (m == NULL) { + /* A mount this UI has not listed yet: ask for the list again. */ + if (st != NULL && strcmp(st, "unmounted") != 0) + ui_ipc_list(); + } else if (st != NULL && strcmp(st, "unmounted") == 0) { + char profile_id[sizeof(m->profile_id)]; + strcpy_s(profile_id, sizeof(profile_id), m->profile_id); + ui_model_remove(&g_app.model, m); + ui_secdlg_on_unmounted(profile_id); + } else if (st != NULL && strlen(st) < sizeof(m->state)) { + strcpy_s(m->state, sizeof(m->state), st); + ui_text_utf8(m->state, m->state_w, SD_COUNTOF(m->state_w)); + ui_model_set_connection(m, data); + } + /* a failed mount with a changed key or the wrong key type: the balloon and its details (5.4, TM-R04) */ + if (sd_json_get(data, "error") != NULL) + ui_hkissue_on_mount_state(data, m != NULL ? m->host : NULL, m != NULL ? m->drive : L""); + ui_main_refresh(); + } else if (name != NULL && strcmp(name, "warning") == 0) { + const char *code = sd_json_get_str(data, "code"); + ui_mount *m = ui_model_find_id(&g_app.model, sd_json_get_str(data, "mount_id")); + unsigned flag = ui_mv_sec_flag(code); + ui_evh_note_live(&g_evh, name, sd_json_get_str(data, "mount_id"), code, sd_unix_time_now()); + if (m != NULL && flag != 0) { + /* the Security column and the details dialog (7.6); a legacy warning names the algorithms */ + m->sec |= flag; + if (flag == UI_MV_SEC_LEGACY && ui_mv_algos_join(sd_json_get(data, "data"), m->algos, sizeof(m->algos)) > 0) + memset(m->algo_cat, 0, sizeof(m->algo_cat)); /* an event names no categories: the dialog derives them */ + ui_main_refresh(); + } + { + ui_nt_kind nk = ui_nt_of_warning_code(code); + if (nk != UI_NT_NONE) + notice_warning(nk, m, data); + else + ui_tray_balloon(code, m ? m->drive : L""); + } + } else if (name != NULL && strcmp(name, "prompt.request") == 0) { + ui_prompt_on_request(data); + } else if (name != NULL && strcmp(name, "prompt.closed") == 0) { + ui_prompt_on_closed(sd_json_get_str(data, "prompt_id"), sd_json_get_str(data, "reason")); + } else if (name != NULL && strcmp(name, "hostkeys.announced") == 0) { + ui_evh_note_live(&g_evh, name, sd_json_get_str(data, "mount_id"), NULL, sd_unix_time_now()); + ui_tray_on_hostkeys_announced(data); + ui_windows_on_event(name, data); + } else if (name != NULL && strncmp(name, "update.", 7) == 0) { + /* update.available, update.progress, update.stale: the Settings group and the balloon (ui_update.c) */ + ui_windows_on_event(name, data); + } + sd_json_free(ev); +} + +void ui_app_on_prompt_list(sd_json *result) +{ + ui_prompt_on_list(result); + if (g_app.elevated_admin) { + /* the admin mode shows every open service prompt: the list carries the same fields as prompt.request */ + const sd_json *arr = sd_json_get(result, "prompts"); + size_t i, n = sd_json_count(arr); + for (i = 0; i < n; i++) + ui_prompt_on_request(sd_json_at(arr, i)); + } + sd_json_free(result); +} + +/* attention {count} from the service (ui_svc.c): the tray shows it to an administrator only. */ +void ui_app_on_attention(int count) +{ + int previous = g_app.attention; + g_app.attention = ui_admin_attention_shown(count, g_app.user_admin) ? count : 0; + ui_tray_update(); + if (ui_nt_attention_grew(previous, g_app.attention)) + ui_tray_notice(UI_NT_SERVICE_ATTENTION, L"", NULL, g_app.attention); /* administrators only (7.7) */ +} + +/* Once a second. The prompt host leaves after UI_PROMPT_IDLE_MS with no open prompt (03 §1.4). */ +void ui_app_on_timer(UINT_PTR id) +{ + if (id == UI_TIMER_NOTIFY) { + ui_tray_flush(); /* queued balloons (7.7), only while some wait */ + return; + } + if (id != UI_TIMER_PROMPT_IDLE || !g_app.prompt_host) + return; + if (ui_prompt_count() > 0) { + g_app.idle_ms = 0; + return; + } + g_app.idle_ms += 1000u; + if (g_app.idle_ms >= UI_PROMPT_IDLE_MS) { + SD_LOGI("ui", "prompt host: no prompt for 60 s, leaving"); + ui_app_quit(); + } +} + +void ui_app_on_reply(ui_ipc_reply *r) +{ + if (r->kind == UI_IPC_AGENT_START) { + ui_main_agent_start_done(r->err); + } else if (r->kind >= UI_IPC_ANNOUNCED) { + ui_windows_on_reply((int)r->kind, r->err, r->result); + } else if (r->kind == UI_IPC_ANSWER) { + /* answers close their dialog when queued; a refused one (answered elsewhere) needs no message */ + if (r->err != SD_OK) + SD_LOGI("ui", "prompt answer not accepted by the agent (%s)", sd_err_name(r->err)); + } else if (r->kind == UI_IPC_MOUNT && r->err != SD_OK) { + /* a refused or failed mount: the mount state events say what happened; this says the request did not start */ + MessageBoxW(g_app.main, sd_l10n_get(SD_MSG_UI_ERROR_MOUNT), L"ShellDrive", MB_OK | MB_ICONERROR); + } else if (r->kind == UI_IPC_UNMOUNT && r->err != SD_OK) { + wchar_t *t = ui_format1(SD_MSG_UI_ERROR_UNMOUNT, "drive", r->drive); + if (t != NULL) { + MessageBoxW(g_app.main, t, L"ShellDrive", MB_OK | MB_ICONERROR); + sd_free(t); + } + } + sd_json_free(r->result); + sd_free(r); +} + +void ui_app_quit(void) +{ + g_app.quit = 1; + PostQuitMessage(0); +} + +/* ---- control window: WM_COPYDATA, IPC results, tray callbacks ---- */ + +static LRESULT on_copydata(HWND sender, const COPYDATASTRUCT *cds) +{ + ui_request req; + if (cds == NULL || cds->dwData != UI_WIRE_CDATA_TAG) { + SD_LOGW("ui", "copydata with an unknown tag: dropped"); + return FALSE; + } + if (!sender_trusted(sender)) { + SD_LOGW("ui", "copydata from another user or a higher integrity level: dropped"); + return FALSE; + } + if (ui_request_from_payload(cds->lpData, cds->cbData, &req) != SD_OK) { + SD_LOGW("ui", "copydata payload rejected: dropped"); + return FALSE; + } + ui_app_handle_request(&req); + return TRUE; +} + +static LRESULT CALLBACK ctrl_proc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) +{ + switch (msg) { + case WM_COPYDATA: + return on_copydata((HWND)wp, (const COPYDATASTRUCT *)lp); + case UI_WM_IPC_STATE: + ui_app_on_agent_state(wp != 0); + return 0; + case UI_WM_IPC_LIST: + ui_app_on_list((sd_json *)lp); + return 0; + case UI_WM_IPC_EVENT: + ui_app_on_event((sd_json *)lp); + return 0; + case UI_WM_IPC_REPLY: + ui_app_on_reply((ui_ipc_reply *)lp); + return 0; + case UI_WM_TRAY: + ui_tray_callback(lp); + return 0; + case UI_WM_IPC_PROMPTS: + ui_app_on_prompt_list((sd_json *)lp); + return 0; + case UI_WM_IPC_HISTORY: + ui_app_on_history((sd_json *)lp); + return 0; + case UI_WM_SVC_ATTENTION: + ui_app_on_attention((int)wp); + return 0; + case WM_TIMER: + ui_app_on_timer(wp); + return 0; + default: + break; + } + return DefWindowProcW(hwnd, msg, wp, lp); +} + +static int register_ctrl_class(HINSTANCE inst) +{ + WNDCLASSEXW wc; + memset(&wc, 0, sizeof(wc)); + wc.cbSize = sizeof(wc); + wc.lpfnWndProc = ctrl_proc; + wc.hInstance = inst; + wc.lpszClassName = g_app.class_ctrl; + return RegisterClassExW(&wc) != 0 || GetLastError() == ERROR_CLASS_ALREADY_EXISTS; +} + +/* Config root of the --test-* smoke modes (a temporary folder unique to this process; removed at exit). */ +static wchar_t g_smoke_root[MAX_PATH + 32]; + +static void remove_smoke_tree(const wchar_t *dir) +{ + wchar_t pat[MAX_PATH + 32], path[MAX_PATH + 32]; + WIN32_FIND_DATAW fd; + HANDLE h; + _snwprintf_s(pat, SD_COUNTOF(pat), _TRUNCATE, L"%s\\*", dir); + h = FindFirstFileW(pat, &fd); + if (h != INVALID_HANDLE_VALUE) { + do { + if (wcscmp(fd.cFileName, L".") == 0 || wcscmp(fd.cFileName, L"..") == 0) continue; + _snwprintf_s(path, SD_COUNTOF(path), _TRUNCATE, L"%s\\%s", dir, fd.cFileName); + if (fd.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) remove_smoke_tree(path); + else DeleteFileW(path); + } while (FindNextFileW(h, &fd)); + FindClose(h); + } + RemoveDirectoryW(dir); +} + +static void build_class_names(void) +{ + wcscpy_s(g_app.class_ctrl, SD_COUNTOF(g_app.class_ctrl), L"com.librestatic.shelldrive.ui."); + wcscat_s(g_app.class_ctrl, SD_COUNTOF(g_app.class_ctrl), g_sid); + wcscpy_s(g_app.class_main, SD_COUNTOF(g_app.class_main), g_app.class_ctrl); + wcscat_s(g_app.class_main, SD_COUNTOF(g_app.class_main), L".main"); +} + +/* Sends the command line to the running instance. The sender is a private message-only window, so the + receiver can check our process (its token user and integrity level). */ +static int forward_to_running(int argc, wchar_t **argv) +{ + unsigned char *buf = NULL; + size_t n = 0; + HWND target, sender; + COPYDATASTRUCT cds; + DWORD_PTR result = 0; + int ok = 0; + + if (ui_wire_encode(argc, (const wchar_t *const *)argv, &buf, &n) != SD_OK) + return 0; + target = FindWindowExW(HWND_MESSAGE, NULL, g_app.class_ctrl, NULL); + sender = CreateWindowExW(0, L"STATIC", L"", 0, 0, 0, 0, 0, HWND_MESSAGE, NULL, g_app.inst, NULL); + if (target != NULL && sender != NULL) { + cds.dwData = UI_WIRE_CDATA_TAG; + cds.cbData = (DWORD)n; + cds.lpData = buf; + ok = SendMessageTimeoutW(target, WM_COPYDATA, (WPARAM)sender, (LPARAM)&cds, SMTO_ABORTIFHUNG, 5000, + &result) != 0 && + result != 0; + } + if (sender != NULL) + DestroyWindow(sender); + sd_free(buf); + return ok; +} + +/* Smoke test (--test-window): the main window class is registered, the window and its controls exist, + and the window stays hidden. */ +static int run_smoke_test(void) +{ + WNDCLASSEXW wc; + HWND w; + int ok; + + if (!ui_main_register_class(g_app.inst, g_app.class_main)) + return 1; + memset(&wc, 0, sizeof(wc)); + wc.cbSize = sizeof(wc); + if (GetClassInfoExW(g_app.inst, g_app.class_main, &wc) == 0) + return 1; + w = ui_main_create(g_app.inst, g_app.class_main, 0); + if (w == NULL || !IsWindow(w)) + return 1; + ok = ui_main_test_controls() && !IsWindowVisible(w); + DestroyWindow(w); + return ok ? 0 : 1; +} + +int WINAPI wWinMain(_In_ HINSTANCE inst, _In_opt_ HINSTANCE prev, _In_ LPWSTR cmd, _In_ int show) +{ + wchar_t **argv = NULL; + int argc = 0, rc = 1; + ui_request req; + wchar_t lang_buf[32]; + const wchar_t *lang = NULL; + DWORD session = 0; + INITCOMMONCONTROLSEX icc; + HANDLE mutex = NULL; + int existed = 0; + sd_err e; + MSG msg; + + (void)prev; + (void)cmd; + (void)show; + sd_platform_harden_dll_search(); + sd_platform_init(); + ui_dpi_init(); + g_app.inst = inst; + g_app.wm_taskbar = RegisterWindowMessageW(L"TaskbarCreated"); + + argv = CommandLineToArgvW(GetCommandLineW(), &argc); + if (argv == NULL) + return 1; + + if (GetEnvironmentVariableW(L"SHELLDRIVE_LANG", lang_buf, SD_COUNTOF(lang_buf)) - 1u < + SD_COUNTOF(lang_buf) - 1u) + lang = lang_buf; + sd_l10n_init(&sd_l10n_catalog_ui, lang); + sd_settings_defaults(&g_app.settings); + (void)sd_settings_load(&g_app.settings); + + memset(&icc, 0, sizeof(icc)); + icc.dwSize = sizeof(icc); + icc.dwICC = ICC_LISTVIEW_CLASSES | ICC_BAR_CLASSES | ICC_TAB_CLASSES; + InitCommonControlsEx(&icc); + + if (sd_ipc_current_identity(g_sid, SD_COUNTOF(g_sid), &session) != SD_OK || !read_self()) + goto done; + build_class_names(); + + if (argc > 1 && (wcscmp(argv[1], L"--test-window") == 0 || wcscmp(argv[1], L"--test-prompts") == 0 || + wcscmp(argv[1], L"--test-editor") == 0 || wcscmp(argv[1], L"--test-windows") == 0 || + wcscmp(argv[1], L"--test-wizard") == 0)) { + /* the smoke tests never read or write the user's profiles: the store is a temporary folder that only + this process uses (PID and stamp in the name, removed at exit), so concurrent runs never share it */ + wchar_t tmp[MAX_PATH]; + if (GetTempPathW(SD_COUNTOF(tmp), tmp) > 0 && wcslen(tmp) < SD_COUNTOF(tmp) - 64) { + LARGE_INTEGER qpc; + QueryPerformanceCounter(&qpc); + _snwprintf_s(g_smoke_root, SD_COUNTOF(g_smoke_root), _TRUNCATE, L"%sshelldrive-ui-test-%lu-%08lx", tmp, + (unsigned long)GetCurrentProcessId(), (unsigned long)qpc.LowPart); + sd_config_set_root_override(g_smoke_root); + } + } + if (argc > 1 && wcscmp(argv[1], L"--test-window") == 0) { + rc = run_smoke_test(); + goto done; + } + if (argc > 1 && wcscmp(argv[1], L"--test-prompts") == 0) { + rc = ui_prompt_smoke_test() ? 0 : 1; + goto done; + } + if (argc > 1 && wcscmp(argv[1], L"--test-editor") == 0) { + rc = ui_editor_smoke_test() ? 0 : 1; + goto done; + } + if (argc > 1 && wcscmp(argv[1], L"--test-windows") == 0) { + rc = ui_windows_smoke_test() ? 0 : 1; + goto done; + } + if (argc > 1 && wcscmp(argv[1], L"--test-wizard") == 0) { + rc = ui_wizard_smoke_test() ? 0 : 1; + goto done; + } + + if (ui_request_from_args(argc - 1, (const wchar_t *const *)(argv + 1), &req) != SD_OK) { + SD_LOGW("ui", "command line not recognized"); + rc = 2; + goto done; + } + + /* set before the IPC thread starts: it reads the flag at its first connect */ + g_app.elevated_admin = req.verb == UI_VERB_ELEVATED_ADMIN; + /* the admin mode has the prompt host's lifetime (no main window, leaves when the service goes or idles) */ + g_app.prompt_host = req.verb == UI_VERB_PROMPT_HOST || g_app.elevated_admin; + g_app.elevated_service = req.verb == UI_VERB_ELEVATED_SERVICE; + if (!g_app.elevated_admin && !g_app.elevated_service) { + /* the admin mode is not a second UI of the user: it never forwards to the running one (no mutex) */ + e = sd_ipc_object_create(SD_IPC_OBJ_UI_MUTEX, &mutex, &existed); + if (e != SD_OK || mutex == NULL) + goto done; + g_app.mutex = mutex; + if (existed) { + rc = forward_to_running(argc - 1, argv + 1) ? 0 : 1; + goto done; + } + } + + /* dark title bars (7.4): the hook is installed before the first window of this process exists */ + ui_dark_init(); + if (!register_ctrl_class(inst) || !ui_main_register_class(inst, g_app.class_main)) + goto done; + g_app.ctrl = CreateWindowExW(0, g_app.class_ctrl, L"", 0, 0, 0, 0, 0, HWND_MESSAGE, NULL, inst, NULL); + if (g_app.ctrl == NULL) + goto done; + if (!g_app.prompt_host && !g_app.elevated_service && ui_main_create(inst, g_app.class_main, 0) == NULL) + goto done; + if (g_app.prompt_host) + SetTimer(g_app.ctrl, UI_TIMER_PROMPT_IDLE, 1000, NULL); + if (g_app.elevated_service) { + /* the Service page alone: no tray, no agent, no service thread (it reads the service on demand) */ + g_app.user_admin = ui_svc_user_is_admin(); + } else if (g_app.elevated_admin) { + /* no tray icon for the admin mode: its prompt dialogs are the window */ + if (ui_svc_start(1) != SD_OK) + goto done; + } else { + /* Explorer may not be ready yet; TaskbarCreated adds the icon later. */ + ui_tray_add(); + if (ui_ipc_start() != SD_OK) + goto done; + /* attention watch (03 §2.6 rule 3): only for a logged-on administrator; other users never connect to the service */ + g_app.user_admin = ui_svc_user_is_admin(); + if (g_app.user_admin) + (void)ui_svc_start(0); + } + ui_app_handle_request(&req); + /* first run (7.5): only for the plain start and "show"; --tray and the prompt host never open it */ + if (g_app.elevated_service && !ui_service_is_open()) + goto done; /* the page could not be created: nothing to wait for */ + if (req.verb == UI_VERB_NONE || req.verb == UI_VERB_SHOW) + ui_wizard_run_if_needed(g_app.main, 0, g_app.prompt_host); + + while (GetMessageW(&msg, NULL, 0, 0) > 0) { + if (ui_prompt_filter(&msg)) + continue; /* IsDialogMessage: keyboard handling of the prompt dialogs */ + TranslateMessage(&msg); + DispatchMessageW(&msg); + } + rc = 0; + + ui_windows_close_all(); + ui_svc_stop(); + ui_ipc_stop(); + ui_tray_remove(); + if (g_app.main != NULL && IsWindow(g_app.main)) + DestroyWindow(g_app.main); + if (g_app.ctrl != NULL && IsWindow(g_app.ctrl)) + DestroyWindow(g_app.ctrl); + ui_dark_shutdown(); + ui_icon_free_all(); + +done: + if (g_smoke_root[0] != 0) { + sd_config_set_root_override(NULL); + remove_smoke_tree(g_smoke_root); + } + if (mutex != NULL) + CloseHandle(mutex); + if (argv != NULL) + LocalFree(argv); + return rc; +} diff --git a/apps/shelldrive-ui/ui_about.c b/apps/shelldrive-ui/ui_about.c new file mode 100644 index 0000000..ba11228 --- /dev/null +++ b/apps/shelldrive-ui/ui_about.c @@ -0,0 +1,233 @@ +/* shelldrive-ui: the About window (03 §1.4, decision D45 identity strings). + * + * Shows the product name, the version (SD_VERSION_STRING, the same text as the version resource), the license + * and the project address. The project button opens the address in the default browser; the address itself is + * not translated. */ +#include +#include +#include +#include + +#include "sd/version.h" +#include "sd_base/sd_mem.h" +#include "sd_l10n/sd_l10n.h" +#include "sd_l10n_ui_ids.h" +#include "ui_dpi.h" +#include "ui_int.h" +#include "ui_res.h" +#include "ui_win.h" + +#define AB_ID_ICON 4501 +#define AB_ID_NAME 4502 +#define AB_ID_VERSION 4503 +#define AB_ID_LICENSE 4504 +#define AB_ID_COPY 4505 +#define AB_ID_URL 4506 +#define AB_ID_PROJECT 4507 +#define AB_ID_CLOSE 4508 +#define AB_CLIENT_W 440 +#define AB_CLIENT_H 300 + +#define AB_URL L"https://github.com/LibreStatic/ShellDrive" + +static struct { + HWND wnd, icon, name, version, license, copyright, url, project, close; + HFONT font, bold; + unsigned dpi; +} g_ab; + +static int dpx(int v) +{ + return ui_dpi_scale(v, g_ab.dpi); +} + +static void layout(HWND hwnd) +{ + RECT rc; + int m, W, H, y, bw1, bw2, lh, iconsz; + if (!g_ab.close) + return; + GetClientRect(hwnd, &rc); + W = rc.right - rc.left; + H = rc.bottom - rc.top; + m = dpx(16); + lh = dpx(22); + iconsz = dpx(48); + MoveWindow(g_ab.icon, m, m, iconsz, iconsz, TRUE); + MoveWindow(g_ab.name, m + iconsz + m, m, W - (m + iconsz + m) - m > 0 ? W - (m + iconsz + m) - m : 0, lh, TRUE); + MoveWindow(g_ab.version, m + iconsz + m, m + lh, W - (m + iconsz + m) - m > 0 ? W - (m + iconsz + m) - m : 0, lh, + TRUE); + y = m + iconsz + m; + MoveWindow(g_ab.license, m, y, W - 2 * m > 0 ? W - 2 * m : 0, lh, TRUE); + y += lh + dpx(4); + MoveWindow(g_ab.copyright, m, y, W - 2 * m > 0 ? W - 2 * m : 0, lh, TRUE); + y += lh + dpx(4); + MoveWindow(g_ab.url, m, y, W - 2 * m > 0 ? W - 2 * m : 0, lh, TRUE); + bw1 = ui_win_text_width(g_ab.font, sd_l10n_get(SD_MSG_UI_ABOUT_BTN_PROJECT)) + dpx(32); + bw2 = ui_win_text_width(g_ab.font, sd_l10n_get(SD_MSG_UI_ABOUT_BTN_CLOSE)) + dpx(32); + if (bw1 < dpx(96)) bw1 = dpx(96); + if (bw2 < dpx(96)) bw2 = dpx(96); + MoveWindow(g_ab.close, W - m - bw2, H - m - dpx(26), bw2, dpx(26), TRUE); + MoveWindow(g_ab.project, W - m - bw2 - dpx(8) - bw1, H - m - dpx(26), bw1, dpx(26), TRUE); +} + +static void create_children(HWND hwnd) +{ + wchar_t version[64]; + wchar_t *t; + sd_arg a; + HINSTANCE inst = g_app.inst; + + g_ab.icon = CreateWindowExW(0, L"STATIC", L"", WS_CHILD | WS_VISIBLE | SS_ICON, 0, 0, 10, 10, hwnd, + (HMENU)(UINT_PTR)AB_ID_ICON, inst, NULL); + SendMessageW(g_ab.icon, STM_SETICON, (WPARAM)LoadIconW(inst, MAKEINTRESOURCEW(UI_IDI_APP)), 0); + g_ab.name = CreateWindowExW(0, L"STATIC", L"ShellDrive", WS_CHILD | WS_VISIBLE | SS_LEFT | SS_NOPREFIX, 0, 0, + 10, 10, hwnd, (HMENU)(UINT_PTR)AB_ID_NAME, inst, NULL); + ui_text_utf8(SD_VERSION_STRING, version, SD_COUNTOF(version)); + a.name = "version"; + a.type = SD_ARG_STR; + a.v.s = version; + t = sd_l10n_format_alloc(SD_MSG_UI_ABOUT_VERSION, &a, 1); + g_ab.version = CreateWindowExW(0, L"STATIC", t ? t : version, WS_CHILD | WS_VISIBLE | SS_LEFT | SS_NOPREFIX, 0, + 0, 10, 10, hwnd, (HMENU)(UINT_PTR)AB_ID_VERSION, inst, NULL); + sd_free(t); + g_ab.license = CreateWindowExW(0, L"STATIC", sd_l10n_get(SD_MSG_UI_ABOUT_LICENSE), + WS_CHILD | WS_VISIBLE | SS_LEFT | SS_NOPREFIX, 0, 0, 10, 10, hwnd, + (HMENU)(UINT_PTR)AB_ID_LICENSE, inst, NULL); + g_ab.copyright = CreateWindowExW(0, L"STATIC", sd_l10n_get(SD_MSG_UI_ABOUT_COPYRIGHT), + WS_CHILD | WS_VISIBLE | SS_LEFT | SS_NOPREFIX, 0, 0, 10, 10, hwnd, + (HMENU)(UINT_PTR)AB_ID_COPY, inst, NULL); + g_ab.url = CreateWindowExW(0, L"STATIC", AB_URL, WS_CHILD | WS_VISIBLE | SS_LEFT | SS_NOPREFIX, 0, 0, 10, 10, + hwnd, (HMENU)(UINT_PTR)AB_ID_URL, inst, NULL); + g_ab.project = CreateWindowExW(0, L"BUTTON", sd_l10n_get(SD_MSG_UI_ABOUT_BTN_PROJECT), + WS_CHILD | WS_VISIBLE | WS_TABSTOP | BS_PUSHBUTTON, 0, 0, 10, 10, hwnd, + (HMENU)(UINT_PTR)AB_ID_PROJECT, inst, NULL); + g_ab.close = CreateWindowExW(0, L"BUTTON", sd_l10n_get(SD_MSG_UI_ABOUT_BTN_CLOSE), + WS_CHILD | WS_VISIBLE | WS_TABSTOP | BS_DEFPUSHBUTTON, 0, 0, 10, 10, hwnd, + (HMENU)(UINT_PTR)AB_ID_CLOSE, inst, NULL); + ui_win_apply_font(hwnd, g_ab.font); + SendMessageW(g_ab.name, WM_SETFONT, (WPARAM)g_ab.bold, MAKELPARAM(TRUE, 0)); +} + +static void on_command(WORD id, WORD code) +{ + if (code != BN_CLICKED) + return; + if (id == AB_ID_PROJECT) { + HINSTANCE r = ShellExecuteW(g_ab.wnd, L"open", AB_URL, NULL, NULL, SW_SHOWNORMAL); + if ((INT_PTR)r <= 32) + ui_win_message(g_ab.wnd, sd_l10n_get(SD_MSG_UI_ABOUT_OPEN_FAILED), MB_OK | MB_ICONWARNING); + } else if (id == AB_ID_CLOSE) { + DestroyWindow(g_ab.wnd); + } +} + +static void make_fonts(unsigned dpi) +{ + LOGFONTW lf; + HFONT base = ui_win_font(dpi); + if (base == NULL) + return; + memset(&lf, 0, sizeof(lf)); + GetObjectW(base, sizeof(lf), &lf); + lf.lfWeight = FW_BOLD; + g_ab.font = base; + g_ab.bold = CreateFontIndirectW(&lf); +} + +static void on_dpi(HWND hwnd, unsigned dpi, const RECT *suggested) +{ + HFONT old = g_ab.font, oldb = g_ab.bold; + if (dpi == 0) + dpi = UI_DPI_BASE; + g_ab.dpi = dpi; + if (suggested) + SetWindowPos(hwnd, NULL, suggested->left, suggested->top, suggested->right - suggested->left, + suggested->bottom - suggested->top, SWP_NOZORDER | SWP_NOACTIVATE); + g_ab.font = NULL; + g_ab.bold = NULL; + make_fonts(dpi); + ui_win_apply_font(hwnd, g_ab.font); + SendMessageW(g_ab.name, WM_SETFONT, (WPARAM)g_ab.bold, MAKELPARAM(TRUE, 0)); + if (old) + DeleteObject(old); + if (oldb) + DeleteObject(oldb); + layout(hwnd); +} + +static LRESULT CALLBACK ab_proc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) +{ + switch (msg) { + case WM_CREATE: + g_ab.wnd = hwnd; + g_ab.dpi = ui_dpi_window(hwnd); + make_fonts(g_ab.dpi); + create_children(hwnd); + layout(hwnd); + return 0; + case WM_SIZE: + layout(hwnd); + return 0; + case WM_COMMAND: + on_command(LOWORD(wp), HIWORD(wp)); + return 0; + case WM_CLOSE: + DestroyWindow(hwnd); + return 0; + case WM_DPICHANGED: + on_dpi(hwnd, (unsigned)LOWORD(wp), (const RECT *)lp); + return 0; + case WM_DESTROY: + if (g_ab.font) + DeleteObject(g_ab.font); + if (g_ab.bold) + DeleteObject(g_ab.bold); + memset(&g_ab, 0, sizeof(g_ab)); + return 0; + default: + break; + } + return DefWindowProcW(hwnd, msg, wp, lp); +} + +static HWND create_window(int visible) +{ + wchar_t cls[320]; + HWND w; + ui_win_class_name(L"about", cls); + if (!ui_win_register(g_app.inst, cls, ab_proc)) + return NULL; + if (g_ab.wnd != NULL && IsWindow(g_ab.wnd)) + return g_ab.wnd; + w = CreateWindowExW(0, cls, sd_l10n_get(SD_MSG_UI_ABOUT_TITLE), WS_CAPTION | WS_SYSMENU | WS_CLIPCHILDREN, + CW_USEDEFAULT, CW_USEDEFAULT, AB_CLIENT_W, AB_CLIENT_H, NULL, NULL, g_app.inst, NULL); + if (w == NULL) + return NULL; + SetWindowPos(w, NULL, 0, 0, ui_dpi_scale(AB_CLIENT_W, ui_dpi_window(w)), + ui_dpi_scale(AB_CLIENT_H, ui_dpi_window(w)), SWP_NOMOVE | SWP_NOZORDER | SWP_NOACTIVATE); + if (visible) + ShowWindow(w, SW_SHOW); + return w; +} + +void ui_about_open(void) +{ + if (g_ab.wnd != NULL && IsWindow(g_ab.wnd)) { + ShowWindow(g_ab.wnd, IsIconic(g_ab.wnd) ? SW_RESTORE : SW_SHOW); + SetForegroundWindow(g_ab.wnd); + return; + } + (void)create_window(1); +} + +void ui_about_close(void) +{ + if (g_ab.wnd != NULL && IsWindow(g_ab.wnd)) + DestroyWindow(g_ab.wnd); +} + +HWND ui_about_create_hidden(void) +{ + return create_window(0); +} diff --git a/apps/shelldrive-ui/ui_admin_model.c b/apps/shelldrive-ui/ui_admin_model.c new file mode 100644 index 0000000..da48e9a --- /dev/null +++ b/apps/shelldrive-ui/ui_admin_model.c @@ -0,0 +1,41 @@ +/* shelldrive-ui: the administrator side of the service prompts. See ui_admin_model.h. Pure. */ +#include + +#include "sd_base/sd_err.h" +#include "sd_base/sd_json.h" +#include "ui_admin_model.h" + +sd_err ui_admin_parse_attention(const sd_json *data, int *count) +{ + int64_t v = 0; + if (count != NULL) + *count = 0; + if (data == NULL || count == NULL || sd_json_type_of(data) != SD_JSON_OBJECT) + return SD_E_INVALID_PARAM; + if (!sd_json_get_int64(data, "count", &v)) + return SD_E_INVALID_PARAM; + if (v < 0 || v > UI_ADMIN_COUNT_MAX) + return SD_E_INVALID_PARAM; + *count = (int)v; + return SD_OK; +} + +int ui_admin_attention_shown(int count, int user_is_admin) +{ + return user_is_admin && count > 0 ? 1 : 0; +} + +int ui_admin_may_answer_service(int process_elevated, int user_is_admin) +{ + return process_elevated && user_is_admin ? 1 : 0; +} + +ui_answer_route ui_admin_answer_route(int elevated_admin_mode) +{ + return elevated_admin_mode ? UI_ROUTE_SERVICE : UI_ROUTE_AGENT; +} + +const wchar_t *ui_admin_launch_verb(int vista_or_later) +{ + return vista_or_later ? L"runas" : L"open"; +} diff --git a/apps/shelldrive-ui/ui_admin_model.h b/apps/shelldrive-ui/ui_admin_model.h new file mode 100644 index 0000000..031302e --- /dev/null +++ b/apps/shelldrive-ui/ui_admin_model.h @@ -0,0 +1,45 @@ +/* shelldrive-ui: the administrator side of the service prompts (03 §2.6 rule 3, §1.4 --elevated-admin, TM-R25). + * + * Pure: no window, IPC or token call. The service pipe client is ui_svc.c; the decisions it and the window code + * take are here, so the unit tests (tests/ui) can check them on their own: + * - the attention count (the content-free attention event) and when the tray shows it: only for a user who is an + * administrator, and only while the count is positive; + * - where an answer goes: a service prompt is answered over the service pipe, and only by an elevated process that + * is an administrator (the service checks the same thing again); + * - the ShellExecute verb that relaunches the UI elevated: "runas" on Windows Vista and later, a plain launch on XP. */ +#ifndef SD_UI_ADMIN_MODEL_H +#define SD_UI_ADMIN_MODEL_H + +#include + +#include "sd/base.h" +#include "sd_base/sd_err.h" +#include "sd_base/sd_json.h" + +/* The largest attention count taken from the wire. The service never has more prompts than its slot table holds. */ +#define UI_ADMIN_COUNT_MAX 1024 + +typedef enum ui_answer_route { + UI_ROUTE_AGENT = 0, /* prompt.answer on the per-user agent pipe */ + UI_ROUTE_SERVICE /* prompt.answer on the service pipe, from the elevated administrator UI */ +} ui_answer_route; + +/* Reads the count of an attention event ({"count": n}). n must be an integer from 0 to UI_ADMIN_COUNT_MAX. + Anything else is SD_E_INVALID_PARAM and *count is left at 0. */ +sd_err ui_admin_parse_attention(const sd_json *data, int *count); + +/* 1 when the tray shows the attention state: the count is positive and the logged-on user is an administrator. + A non-administrator never sees it, even when the service sends the count. */ +int ui_admin_attention_shown(int count, int user_is_admin); + +/* 1 when this process may answer service prompts at all: it is an elevated process of an administrator. The service + refuses the answer otherwise (access_denied), so this only avoids a pointless call. */ +int ui_admin_may_answer_service(int process_elevated, int user_is_admin); + +/* Where an answer goes. The admin mode answers service prompts on the service pipe; the normal UI answers its agent. */ +ui_answer_route ui_admin_answer_route(int elevated_admin_mode); + +/* ShellExecute verb for the relaunch: "runas" on Windows Vista and later (UAC), "open" on XP (no elevation there). */ +const wchar_t *ui_admin_launch_verb(int vista_or_later); + +#endif /* SD_UI_ADMIN_MODEL_H */ diff --git a/apps/shelldrive-ui/ui_dark.c b/apps/shelldrive-ui/ui_dark.c new file mode 100644 index 0000000..6e32831 --- /dev/null +++ b/apps/shelldrive-ui/ui_dark.c @@ -0,0 +1,87 @@ +/* shelldrive-ui: dark mode of the title bars (03 §7.4). See ui_dark.h. + * + * Only documented behaviour is used: DWMWA_USE_IMMERSIVE_DARK_MODE on build 22000 and later, read from the + * per-user theme value AppsUseLightTheme. The undocumented uxtheme ordinals are not used (7.4). */ +#include + +#include "sd_platform/sd_platform.h" +#include "ui_dark.h" +#include "ui_dark_model.h" + +typedef HRESULT (WINAPI *ui_pfn_DwmSetWindowAttribute)(HWND hwnd, DWORD attribute, LPCVOID value, DWORD size); + +static ui_pfn_DwmSetWindowAttribute g_dwm_set; +static HHOOK g_hook; +static int g_dark; + +static int read_apps_use_light(void) +{ + HKEY key; + DWORD value = 0, type = 0, cb = sizeof(value); + LONG r; + if (RegOpenKeyExW(HKEY_CURRENT_USER, L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize", 0, + KEY_QUERY_VALUE, &key) != ERROR_SUCCESS) + return -1; + r = RegQueryValueExW(key, L"AppsUseLightTheme", NULL, &type, (LPBYTE)&value, &cb); + RegCloseKey(key); + if (r != ERROR_SUCCESS || type != REG_DWORD || cb != sizeof(value)) + return -1; + return value ? 1 : 0; +} + +static void apply_one(HWND hwnd, int dark) +{ + BOOL on = dark ? TRUE : FALSE; + if (g_dwm_set != NULL && hwnd != NULL) + g_dwm_set(hwnd, UI_DARK_ATTRIBUTE, &on, sizeof(on)); +} + +static int is_captioned(HWND hwnd) +{ + return (GetWindowLongW(hwnd, GWL_STYLE) & WS_CAPTION) == WS_CAPTION; +} + +static BOOL CALLBACK enum_cb(HWND hwnd, LPARAM dark) +{ + if (is_captioned(hwnd)) + apply_one(hwnd, (int)dark); + return TRUE; +} + +/* A window that is activated for the first time gets the attribute before it is seen in its own mode. */ +static LRESULT CALLBACK cbt_proc(int code, WPARAM wp, LPARAM lp) +{ + if (code == HCBT_ACTIVATE && g_dark && wp != 0 && is_captioned((HWND)wp)) + apply_one((HWND)wp, 1); + return CallNextHookEx(g_hook, code, wp, lp); +} + +void ui_dark_refresh(void) +{ + const sd_os_version *v = sd_os_version_get(); + int want = ui_dark_wanted(v ? v->build : 0, g_dwm_set != NULL, read_apps_use_light()); + g_dark = want; + EnumThreadWindows(GetCurrentThreadId(), enum_cb, (LPARAM)want); +} + +void ui_dark_init(void) +{ + g_dwm_set = (ui_pfn_DwmSetWindowAttribute)sd_platform_resolve_by_name(L"dwmapi.dll", "DwmSetWindowAttribute"); + if (g_dwm_set != NULL && sd_os_at_least(10, 0, UI_DARK_MIN_BUILD) && g_hook == NULL) + g_hook = SetWindowsHookExW(WH_CBT, cbt_proc, NULL, GetCurrentThreadId()); + ui_dark_refresh(); +} + +void ui_dark_shutdown(void) +{ + if (g_hook != NULL) + UnhookWindowsHookEx(g_hook); + g_hook = NULL; + g_dwm_set = NULL; + g_dark = 0; +} + +int ui_dark_active(void) +{ + return g_dark; +} diff --git a/apps/shelldrive-ui/ui_dark.h b/apps/shelldrive-ui/ui_dark.h new file mode 100644 index 0000000..7856403 --- /dev/null +++ b/apps/shelldrive-ui/ui_dark.h @@ -0,0 +1,17 @@ +/* shelldrive-ui: dark mode of the title bars (03 §7.4). The decision is ui_dark_model.c (pure). + * + * ui_dark_init resolves DwmSetWindowAttribute at run time (sd_platform_resolve_by_name, never imported) and + * installs a thread WH_CBT hook, so every window of this thread gets the title bar attribute when it is activated. + * ui_dark_refresh re-reads the app theme and applies it to every captioned window of this thread; the main window + * calls it on WM_SETTINGCHANGE "ImmersiveColorSet". Only the UI thread calls these functions. */ +#ifndef SD_UI_DARK_H +#define SD_UI_DARK_H + +void ui_dark_init(void); +void ui_dark_shutdown(void); +/* Re-reads AppsUseLightTheme and applies the decision to the windows of this thread. */ +void ui_dark_refresh(void); +/* 1 when the last decision was dark. */ +int ui_dark_active(void); + +#endif /* SD_UI_DARK_H */ diff --git a/apps/shelldrive-ui/ui_dark_model.c b/apps/shelldrive-ui/ui_dark_model.c new file mode 100644 index 0000000..14718d0 --- /dev/null +++ b/apps/shelldrive-ui/ui_dark_model.c @@ -0,0 +1,11 @@ +/* shelldrive-ui: the dark mode decision (03 §7.4). See ui_dark_model.h. */ +#include + +#include "ui_dark_model.h" + +int ui_dark_wanted(unsigned os_build, int dwm_present, int apps_use_light) +{ + if (os_build < UI_DARK_MIN_BUILD || !dwm_present) + return 0; /* XP, 7, 8, 8.1, 10: stay light (03 §7.4) */ + return apps_use_light == 0 ? 1 : 0; /* -1 (absent) and 1 (light apps) are light */ +} diff --git a/apps/shelldrive-ui/ui_dark_model.h b/apps/shelldrive-ui/ui_dark_model.h new file mode 100644 index 0000000..4c77c80 --- /dev/null +++ b/apps/shelldrive-ui/ui_dark_model.h @@ -0,0 +1,21 @@ +/* shelldrive-ui: the dark mode decision (03 §7.4). + * + * v1 follows the system only where Windows documents it: the title bar of our windows uses the immersive dark + * attribute (DWMWA_USE_IMMERSIVE_DARK_MODE) on Windows 11 build 22000 and later, when the user chose the dark app + * theme. Everything else stays light; client areas use system colors, so High Contrast renders correctly. + * + * Pure: no window or process calls, so the unit tests (tests/ui) build this file on its own. */ +#ifndef SD_UI_DARK_MODEL_H +#define SD_UI_DARK_MODEL_H + +#include + +#define UI_DARK_MIN_BUILD 22000u /* Windows 11: DWMWA_USE_IMMERSIVE_DARK_MODE is documented from here */ +#define UI_DARK_ATTRIBUTE 20 /* DWMWA_USE_IMMERSIVE_DARK_MODE */ + +/* 1 when the title bars must use the dark attribute. os_build: the OS build number (0 when unknown). + dwm_present: DwmSetWindowAttribute was resolved at run time. apps_use_light: AppsUseLightTheme under + HKCU\...\Themes\Personalize, 0 (dark apps), 1 (light apps), or -1 when the value is absent (light). */ +int ui_dark_wanted(unsigned os_build, int dwm_present, int apps_use_light); + +#endif /* SD_UI_DARK_MODEL_H */ diff --git a/apps/shelldrive-ui/ui_dlg.c b/apps/shelldrive-ui/ui_dlg.c new file mode 100644 index 0000000..ad48f72 --- /dev/null +++ b/apps/shelldrive-ui/ui_dlg.c @@ -0,0 +1,428 @@ +/* shelldrive-ui: in-memory dialog templates (03 §7.1). See ui_dlg.h. + * + * Binary layout (DLGTEMPLATEEX, wDlgVer 1): + * header: WORD ver, WORD 0xFFFF, DWORD help, DWORD exstyle, DWORD style, WORD count, short x y cx cy, + * WORD menu (0), WORD class (0), WSTR title, [DS_SETFONT] WORD points, WORD weight, BYTE italic, + * BYTE charset, WSTR face; then padding to DWORD. + * item: DWORD help, DWORD exstyle, DWORD style, short x y cx cy, DWORD id, WORD 0xFFFF, WORD class + * ordinal (0x80 button, 0x81 edit, 0x82 static), WSTR text, WORD 0 (no creation data); then padding + * to DWORD before the next item. + * ui_dlg_parse_check walks the blob with the same rules the dialog manager uses. */ +#include +#include +#include + +#include "sd_base/sd_mem.h" +#include "ui_dlg.h" + +#define DLG_SIGNATURE 0xFFFFu +#define DLG_VERSION 1u +#define CLASS_BUTTON 0x0080u +#define CLASS_EDIT 0x0081u +#define CLASS_STATIC 0x0082u +#define CLASS_COMBO 0x0085u +#define DLG_MIN_TEXT_DLU 8 +#define DLG_TEXT_PAD 2 +#define DLG_BUTTON_PAD 8 +#define DLG_DEFAULT_MARGIN 7 + +/* ---- growable byte buffer over sd_alloc ---- */ + +typedef struct dbuf { + unsigned char *p; + size_t len, cap; + int err; +} dbuf; + +static void db_reserve(dbuf *b, size_t extra) +{ + size_t need = b->len + extra, cap; + unsigned char *np; + if (b->err || need <= b->cap) + return; + cap = b->cap ? b->cap : 256; + while (cap < need) + cap *= 2; + np = (unsigned char *)sd_realloc(b->p, cap); + if (np == NULL) { + b->err = 1; + return; + } + b->p = np; + b->cap = cap; +} + +static void db_u8(dbuf *b, unsigned v) +{ + db_reserve(b, 1); + if (!b->err) + b->p[b->len++] = (unsigned char)(v & 0xFFu); +} + +static void db_u16(dbuf *b, unsigned v) +{ + db_u8(b, v & 0xFFu); + db_u8(b, (v >> 8) & 0xFFu); +} + +static void db_u32(dbuf *b, unsigned long v) +{ + db_u16(b, (unsigned)(v & 0xFFFFul)); + db_u16(b, (unsigned)((v >> 16) & 0xFFFFul)); +} + +static void db_wstr(dbuf *b, const wchar_t *s) +{ + size_t i, n = s ? wcslen(s) : 0; + for (i = 0; i < n; i++) + db_u16(b, (unsigned)(unsigned short)s[i]); + db_u16(b, 0); +} + +static void db_align4(dbuf *b) +{ + while (b->len % 4 != 0) + db_u8(b, 0); +} + +/* ---- builder ---- */ + +sd_err ui_dlg_init(ui_dlg *d, const wchar_t *title, int width_dlu, const wchar_t *face, WORD points, + ui_dlg_measure_fn measure, void *measure_ctx) +{ + if (d == NULL || measure == NULL || width_dlu < 100 || width_dlu > 1000) + return SD_E_INVALID_PARAM; + memset(d, 0, sizeof(*d)); + wcsncpy_s(d->title, SD_COUNTOF(d->title), title ? title : L"", _TRUNCATE); + wcsncpy_s(d->face, SD_COUNTOF(d->face), face ? face : L"MS Shell Dlg", _TRUNCATE); + d->points = points ? points : 8; + d->width = width_dlu; + d->margin = DLG_DEFAULT_MARGIN; + d->cursor = DLG_DEFAULT_MARGIN; + d->min_height = 0; + d->style = WS_POPUP | WS_CAPTION | WS_SYSMENU | DS_MODALFRAME | DS_CENTER; + d->measure = measure; + d->measure_ctx = measure_ctx; + return SD_OK; +} + +static ui_dlg_item *next_item(ui_dlg *d) +{ + ui_dlg_item *it; + if (d->count >= UI_DLG_MAX_ITEMS) + return NULL; + it = &d->items[d->count++]; + memset(it, 0, sizeof(*it)); + return it; +} + +static void set_text(ui_dlg_item *it, const wchar_t *text) +{ + wcsncpy_s(it->text, SD_COUNTOF(it->text), text ? text : L"", _TRUNCATE); +} + +static int text_height(const ui_dlg *d, const wchar_t *text) +{ + int h = d->measure(d->measure_ctx, text ? text : L"", d->width - 2 * d->margin, NULL); + return h < DLG_MIN_TEXT_DLU ? DLG_MIN_TEXT_DLU : h; +} + +sd_err ui_dlg_text(ui_dlg *d, WORD id, const wchar_t *text, DWORD extra) +{ + ui_dlg_item *it; + int h; + if (d == NULL || (it = next_item(d)) == NULL) + return SD_E_INVALID_PARAM; + h = text_height(d, text) + DLG_TEXT_PAD; + it->cls = UI_DLG_STATIC; + it->id = id; + it->style = WS_CHILD | WS_VISIBLE | SS_LEFT | extra; + it->x = (short)d->margin; + it->y = (short)d->cursor; + it->cx = (short)(d->width - 2 * d->margin); + it->cy = (short)h; + set_text(it, text); + d->cursor += h + UI_DLG_GAP; + return SD_OK; +} + +sd_err ui_dlg_edit(ui_dlg *d, WORD id, DWORD extra) +{ + ui_dlg_item *it; + if (d == NULL || (it = next_item(d)) == NULL) + return SD_E_INVALID_PARAM; + it->cls = UI_DLG_EDIT; + it->id = id; + it->style = WS_CHILD | WS_VISIBLE | WS_BORDER | WS_TABSTOP | ES_AUTOHSCROLL | ES_LEFT | extra; + it->x = (short)d->margin; + it->y = (short)d->cursor; + it->cx = (short)(d->width - 2 * d->margin); + it->cy = UI_DLG_EDIT_HEIGHT; + d->cursor += UI_DLG_EDIT_HEIGHT + UI_DLG_GAP; + return SD_OK; +} + +sd_err ui_dlg_multiline(ui_dlg *d, WORD id, const wchar_t *text, int max_lines) +{ + ui_dlg_item *it; + int h; + if (d == NULL || max_lines < 1 || (it = next_item(d)) == NULL) + return SD_E_INVALID_PARAM; + h = text_height(d, text) + DLG_TEXT_PAD; + if (h > max_lines * 8) + h = max_lines * 8; + if (h < 16) + h = 16; + it->cls = UI_DLG_EDIT; + it->id = id; + it->style = WS_CHILD | WS_VISIBLE | WS_BORDER | WS_TABSTOP | WS_VSCROLL | ES_MULTILINE | + ES_AUTOVSCROLL | ES_READONLY | ES_LEFT; + it->x = (short)d->margin; + it->y = (short)d->cursor; + it->cx = (short)(d->width - 2 * d->margin); + it->cy = (short)h; + set_text(it, text); + d->cursor += h + UI_DLG_GAP; + return SD_OK; +} + +sd_err ui_dlg_combo(ui_dlg *d, WORD id) +{ + ui_dlg_item *it; + if (d == NULL || (it = next_item(d)) == NULL) + return SD_E_INVALID_PARAM; + it->cls = UI_DLG_COMBO; + it->id = id; + it->style = WS_CHILD | WS_VISIBLE | WS_TABSTOP | WS_VSCROLL | CBS_DROPDOWNLIST | CBS_HASSTRINGS | CBS_AUTOHSCROLL; + it->x = (short)d->margin; + it->y = (short)d->cursor; + it->cx = (short)(d->width - 2 * d->margin); + it->cy = UI_DLG_EDIT_HEIGHT; + d->cursor += UI_DLG_EDIT_HEIGHT + UI_DLG_GAP; + return SD_OK; +} + +sd_err ui_dlg_check(ui_dlg *d, WORD id, const wchar_t *text) +{ + ui_dlg_item *it; + int h; + if (d == NULL || (it = next_item(d)) == NULL) + return SD_E_INVALID_PARAM; + h = text_height(d, text); + if (h < UI_DLG_CHECK_HEIGHT) + h = UI_DLG_CHECK_HEIGHT; + it->cls = UI_DLG_CHECK; + it->id = id; + it->style = WS_CHILD | WS_VISIBLE | WS_TABSTOP | BS_AUTOCHECKBOX | BS_MULTILINE | BS_LEFT; + it->x = (short)d->margin; + it->y = (short)d->cursor; + it->cx = (short)(d->width - 2 * d->margin); + it->cy = (short)h; + set_text(it, text); + d->cursor += h + UI_DLG_GAP; + return SD_OK; +} + +sd_err ui_dlg_space(ui_dlg *d, int dlu) +{ + if (d == NULL || dlu < 0) + return SD_E_INVALID_PARAM; + d->cursor += dlu; + return SD_OK; +} + +sd_err ui_dlg_buttons(ui_dlg *d, const WORD *ids, const wchar_t *const *texts, size_t n, WORD def_id) +{ + int avail; + size_t start = 0; + if (d == NULL || ids == NULL || texts == NULL || n == 0 || n > UI_DLG_MAX_ITEMS) + return SD_E_INVALID_PARAM; + if (d->count + n > UI_DLG_MAX_ITEMS) + return SD_E_INVALID_PARAM; + avail = d->width - 2 * d->margin; + while (start < n) { + int widths[UI_DLG_MAX_ITEMS]; + int total = 0, x; + size_t i, end = start; + for (i = start; i < n; i++) { + int nat = 0, w; + (void)d->measure(d->measure_ctx, texts[i], d->width, &nat); + w = nat + 2 * DLG_BUTTON_PAD; + if (w < UI_DLG_BUTTON_MIN) + w = UI_DLG_BUTTON_MIN; + if (i > start && total + UI_DLG_GAP + w > avail) + break; + widths[i] = w; + total += (i > start ? UI_DLG_GAP : 0) + w; + end = i + 1; + } + x = d->margin + avail - total; + for (i = start; i < end; i++) { + ui_dlg_item *it = next_item(d); + if (it == NULL) + return SD_E_INVALID_PARAM; + it->cls = UI_DLG_BUTTON; + it->id = ids[i]; + it->style = WS_CHILD | WS_VISIBLE | WS_TABSTOP | + ((def_id != 0 && ids[i] == def_id) ? BS_DEFPUSHBUTTON : BS_PUSHBUTTON); + it->x = (short)x; + it->y = (short)d->cursor; + it->cx = (short)widths[i]; + it->cy = UI_DLG_BUTTON_HEIGHT; + set_text(it, texts[i]); + x += widths[i] + UI_DLG_GAP; + } + d->cursor += UI_DLG_BUTTON_HEIGHT + UI_DLG_GAP; + start = end; + } + return SD_OK; +} + +sd_err ui_dlg_build(ui_dlg *d, unsigned char **out, size_t *out_bytes) +{ + dbuf b; + size_t i; + int height; + if (d == NULL || out == NULL || out_bytes == NULL) + return SD_E_INVALID_PARAM; + *out = NULL; + *out_bytes = 0; + memset(&b, 0, sizeof(b)); + height = d->cursor + d->margin; + if (height < d->min_height) + height = d->min_height; + if (height > 0x7FFF) + return SD_E_INVALID_PARAM; + + db_u16(&b, DLG_VERSION); + db_u16(&b, DLG_SIGNATURE); + db_u32(&b, 0); /* help id */ + db_u32(&b, 0); /* extended style */ + db_u32(&b, d->style | DS_SETFONT); + db_u16(&b, (unsigned)d->count); + db_u16(&b, 0); /* x */ + db_u16(&b, 0); /* y */ + db_u16(&b, (unsigned)d->width); + db_u16(&b, (unsigned)height); + db_u16(&b, 0); /* no menu */ + db_u16(&b, 0); /* default window class */ + db_wstr(&b, d->title); + db_u16(&b, d->points); + db_u16(&b, FW_NORMAL); + db_u8(&b, 0); /* not italic */ + db_u8(&b, DEFAULT_CHARSET); + db_wstr(&b, d->face); + + for (i = 0; i < d->count; i++) { + const ui_dlg_item *it = &d->items[i]; + unsigned ord = it->cls == UI_DLG_EDIT ? CLASS_EDIT + : it->cls == UI_DLG_STATIC ? CLASS_STATIC + : it->cls == UI_DLG_COMBO ? CLASS_COMBO : CLASS_BUTTON; + db_align4(&b); + db_u32(&b, 0); /* help id */ + db_u32(&b, 0); /* extended style */ + db_u32(&b, it->style); + db_u16(&b, (unsigned)(unsigned short)it->x); + db_u16(&b, (unsigned)(unsigned short)it->y); + db_u16(&b, (unsigned)(unsigned short)it->cx); + db_u16(&b, (unsigned)(unsigned short)it->cy); + db_u32(&b, it->id); + db_u16(&b, DLG_SIGNATURE); /* class is an ordinal */ + db_u16(&b, ord); + db_wstr(&b, it->text); + db_u16(&b, 0); /* no creation data */ + } + db_align4(&b); + + if (b.err || b.p == NULL) { + sd_free(b.p); + return SD_E_NO_MEMORY; + } + *out = b.p; + *out_bytes = b.len; + return SD_OK; +} + +/* ---- parser (tests) ---- */ + +static int rd_u16(const unsigned char *p, size_t n, size_t *pos, unsigned *v) +{ + if (*pos + 2 > n) + return 0; + *v = p[*pos] | ((unsigned)p[*pos + 1] << 8); + *pos += 2; + return 1; +} + +static int rd_u32(const unsigned char *p, size_t n, size_t *pos, unsigned long *v) +{ + unsigned lo, hi; + if (!rd_u16(p, n, pos, &lo) || !rd_u16(p, n, pos, &hi)) + return 0; + *v = lo | ((unsigned long)hi << 16); + return 1; +} + +static int skip_wstr(const unsigned char *p, size_t n, size_t *pos) +{ + unsigned c; + do { + if (!rd_u16(p, n, pos, &c)) + return 0; + } while (c != 0); + return 1; +} + +static void align_pos(size_t *pos) +{ + while (*pos % 4 != 0) + (*pos)++; +} + +sd_err ui_dlg_parse_check(const unsigned char *blob, size_t bytes, size_t *count, size_t *offsets, size_t cap) +{ + size_t pos = 0, n = 0; + unsigned v, nitems, i; + unsigned long style = 0; + + if (blob == NULL || count == NULL) + return SD_E_INVALID_PARAM; + if (!rd_u16(blob, bytes, &pos, &v) || v != DLG_VERSION) + return SD_E_INVALID_PARAM; + if (!rd_u16(blob, bytes, &pos, &v) || v != DLG_SIGNATURE) + return SD_E_INVALID_PARAM; + pos += 8; /* help id, extended style */ + if (!rd_u32(blob, bytes, &pos, &style) || !rd_u16(blob, bytes, &pos, &nitems)) + return SD_E_INVALID_PARAM; + pos += 8; /* x y cx cy */ + pos += 4; /* menu and class (both 0) */ + if (!skip_wstr(blob, bytes, &pos)) + return SD_E_INVALID_PARAM; + if (style & DS_SETFONT) { + pos += 4; /* points, weight */ + pos += 2; /* italic, charset */ + if (!skip_wstr(blob, bytes, &pos)) + return SD_E_INVALID_PARAM; + } + align_pos(&pos); + for (i = 0; i < nitems; i++) { + if (pos > bytes) + return SD_E_INVALID_PARAM; + if (offsets != NULL && n < cap) + offsets[n] = pos; + n++; + pos += 24; /* help id .. id */ + if (!rd_u16(blob, bytes, &pos, &v) || v != DLG_SIGNATURE) + return SD_E_INVALID_PARAM; + pos += 2; /* class ordinal */ + if (!skip_wstr(blob, bytes, &pos)) + return SD_E_INVALID_PARAM; + if (!rd_u16(blob, bytes, &pos, &v) || v != 0) + return SD_E_INVALID_PARAM; + align_pos(&pos); + } + if (pos != bytes) + return SD_E_INVALID_PARAM; + *count = n; + return SD_OK; +} diff --git a/apps/shelldrive-ui/ui_dlg.h b/apps/shelldrive-ui/ui_dlg.h new file mode 100644 index 0000000..8f8ed08 --- /dev/null +++ b/apps/shelldrive-ui/ui_dlg.h @@ -0,0 +1,99 @@ +/* shelldrive-ui: in-memory dialog templates (03 §7.1). + * + * Dialogs are DLGTEMPLATEEX blobs built here from a small list of controls. The layout is a vertical stack + * in dialog units: each text control is measured by a caller-supplied function (the real one measures with + * DrawTextW and the dialog font, the tests use a fixed rule), so localized text is never clipped. The + * dialog manager still owns the keyboard: tab order follows the order of the items, mnemonics and the + * default and cancel buttons work through IsDialogMessage. + * + * Pure: no window or process calls; compiled into the unit tests (SD_UI_PURE_SOURCES). */ +#ifndef SD_UI_DLG_H +#define SD_UI_DLG_H + +#include + +#include "sd/base.h" +#include "sd_base/sd_err.h" + +#define UI_DLG_MAX_ITEMS 40 +#define UI_DLG_TEXT_MAX 512 /* UTF-16 units of one control text, no terminator */ +#define UI_DLG_TITLE_MAX 256 +#define UI_DLG_FACE_MAX 32 /* LF_FACESIZE */ + +typedef enum ui_dlg_class { + UI_DLG_STATIC = 0, + UI_DLG_EDIT, + UI_DLG_BUTTON, + UI_DLG_CHECK, + UI_DLG_COMBO +} ui_dlg_class; + +typedef struct ui_dlg_item { + ui_dlg_class cls; + WORD id; + DWORD style; /* WS_* / control style, without WS_CHILD or WS_VISIBLE */ + short x, y, cx, cy; /* dialog units, relative to the client area */ + wchar_t text[UI_DLG_TEXT_MAX + 1]; +} ui_dlg_item; + +/* Measures text in dialog units. width_dlu is the available width. Returns the height in dialog units + of the wrapped text and writes the natural (unwrapped) width to *natural_dlu when non-NULL. */ +typedef int (*ui_dlg_measure_fn)(void *ctx, const wchar_t *text, int width_dlu, int *natural_dlu); + +typedef struct ui_dlg { + wchar_t title[UI_DLG_TITLE_MAX + 1]; + wchar_t face[UI_DLG_FACE_MAX]; + WORD points; /* font size in points */ + int width; /* dialog width in dialog units */ + int cursor; /* next free y, dialog units */ + int margin; /* left, right and bottom margin */ + int min_height; /* dialog units: the height is at least this (pages of one sheet) */ + DWORD style; /* dialog style, DS_SETFONT is added by ui_dlg_build */ + ui_dlg_measure_fn measure; + void *measure_ctx; + size_t count; + ui_dlg_item items[UI_DLG_MAX_ITEMS]; +} ui_dlg; + +/* Starts a dialog. face/points: the dialog font (the message font of the system). */ +sd_err ui_dlg_init(ui_dlg *d, const wchar_t *title, int width_dlu, const wchar_t *face, WORD points, + ui_dlg_measure_fn measure, void *measure_ctx); + +/* Static text, full width, wrapped, measured. extra: SS_* style bits. */ +sd_err ui_dlg_text(ui_dlg *d, WORD id, const wchar_t *text, DWORD extra); + +/* Single-line edit, full width. extra: ES_* bits (ES_PASSWORD, ES_READONLY). */ +sd_err ui_dlg_edit(ui_dlg *d, WORD id, DWORD extra); + +/* Read-only multi-line edit with a vertical scroll bar, height measured from text, at most max_lines. */ +sd_err ui_dlg_multiline(ui_dlg *d, WORD id, const wchar_t *text, int max_lines); + +/* Check box, full width. */ +sd_err ui_dlg_check(ui_dlg *d, WORD id, const wchar_t *text); /* starts unchecked; the caller sets BM_SETCHECK */ + +/* Drop-down list (CBS_DROPDOWNLIST), full width. The template height is the closed height of the box; the + page code grows the box while the list is open (ui_editor.c, combo_resize). Items are added at run time. */ +sd_err ui_dlg_combo(ui_dlg *d, WORD id); + +/* Vertical space in dialog units. */ +sd_err ui_dlg_space(ui_dlg *d, int dlu); + +/* Push buttons, right aligned, in rows that wrap when they do not fit. def_id: BS_DEFPUSHBUTTON (0 = none). */ +sd_err ui_dlg_buttons(ui_dlg *d, const WORD *ids, const wchar_t *const *texts, size_t n, WORD def_id); + +/* Serializes the dialog: DLGTEMPLATEEX, WORD-aligned header and title, DWORD-aligned items. The dialog + height is set to the cursor plus the bottom margin. Release *out with sd_free. */ +sd_err ui_dlg_build(ui_dlg *d, unsigned char **out, size_t *out_bytes); + +/* Offsets of the items of a built template, for the tests: returns the item count and fills the + offsets (up to cap) of each DLGITEMTEMPLATEEX. Returns SD_E_INVALID_PARAM on a malformed blob. */ +sd_err ui_dlg_parse_check(const unsigned char *blob, size_t bytes, size_t *count, size_t *offsets, size_t cap); + +/* Dialog-unit helpers used by the layout (dialog units: x = base/4, y = base/8 pixels). */ +#define UI_DLG_BUTTON_MIN 50 +#define UI_DLG_EDIT_HEIGHT 14 +#define UI_DLG_CHECK_HEIGHT 10 +#define UI_DLG_BUTTON_HEIGHT 14 +#define UI_DLG_GAP 4 + +#endif /* SD_UI_DLG_H */ diff --git a/apps/shelldrive-ui/ui_dpi.c b/apps/shelldrive-ui/ui_dpi.c new file mode 100644 index 0000000..5478117 --- /dev/null +++ b/apps/shelldrive-ui/ui_dpi.c @@ -0,0 +1,89 @@ +/* shelldrive-ui: DPI scaling helpers (ui_dpi.h). */ +#include + +#include "sd_platform/sd_platform.h" +#include "ui_dpi.h" + +static unsigned g_system_dpi = UI_DPI_BASE; +static int g_inited; + +static unsigned clamp_dpi(unsigned dpi) +{ + if (dpi == 0) + return UI_DPI_BASE; + if (dpi < 48u) + return 48u; + if (dpi > 960u) + return 960u; + return dpi; +} + +int ui_dpi_scale(int value, unsigned dpi) +{ + unsigned long long mag, r; + unsigned long long v = value < 0 ? (unsigned long long)(-(long long)value) : (unsigned long long)value; + dpi = clamp_dpi(dpi); + mag = v * (unsigned long long)dpi; + r = (mag + UI_DPI_BASE / 2u) / UI_DPI_BASE; + if (r > 0x7FFFFFFFull) + r = 0x7FFFFFFFull; + return value < 0 ? -(int)r : (int)r; +} + +int ui_dpi_font_height(int points, unsigned dpi) +{ + /* A point is 1/72 inch; the height at 96 DPI is points * 96 / 72, rounded, then scaled. */ + int px96 = (int)(((long long)(points < 0 ? -points : points) * UI_DPI_BASE + 36) / 72); + int px = ui_dpi_scale(px96, dpi); + return -px; +} + +void ui_dpi_init(void) +{ + sd_pfn_GetDpiForSystem get_system; + HDC dc; + + if (g_inited) + return; + g_inited = 1; + get_system = (sd_pfn_GetDpiForSystem)sd_platform_resolve(SD_API_GetDpiForSystem); + if (get_system) { + UINT d = get_system(); + g_system_dpi = clamp_dpi(d); + return; + } + dc = GetDC(NULL); + if (dc) { + int d = GetDeviceCaps(dc, LOGPIXELSX); + ReleaseDC(NULL, dc); + g_system_dpi = clamp_dpi(d > 0 ? (unsigned)d : 0u); + } +} + +unsigned ui_dpi_system(void) +{ + return g_system_dpi; +} + +unsigned ui_dpi_window(HWND hwnd) +{ + sd_pfn_GetDpiForWindow get_window; + sd_pfn_GetDpiForMonitor get_monitor; + UINT d; + + get_window = (sd_pfn_GetDpiForWindow)sd_platform_resolve(SD_API_GetDpiForWindow); + if (get_window && hwnd) { + d = get_window(hwnd); + if (d != 0) + return clamp_dpi(d); + } + get_monitor = (sd_pfn_GetDpiForMonitor)sd_platform_resolve(SD_API_GetDpiForMonitor); + if (get_monitor && hwnd) { + HMONITOR mon = MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST); + UINT x = 0, y = 0; + /* MDT_EFFECTIVE_DPI = 0 */ + if (mon && get_monitor(mon, 0, &x, &y) == S_OK && x != 0) + return clamp_dpi(x); + } + return g_system_dpi; +} diff --git a/apps/shelldrive-ui/ui_dpi.h b/apps/shelldrive-ui/ui_dpi.h new file mode 100644 index 0000000..e2a940f --- /dev/null +++ b/apps/shelldrive-ui/ui_dpi.h @@ -0,0 +1,31 @@ +/* shelldrive-ui: DPI scaling (03 §7.3, XP..11). + * + * The pure helpers (ui_dpi_scale, ui_dpi_font_height) are unit tested. The queries resolve the + * Windows 8.1 and 10 APIs at run time (sd_platform_resolve, never imported: the floor lists of + * tests/os-imports must stay XP-compatible). XP and Windows 7 use the system DPI read from a screen DC. + * Per-monitor DPI on 8.1 and later comes from GetDpiForMonitor, per-window DPI on 10 1607 and later + * from GetDpiForWindow. WM_DPICHANGED is handled by the main window (ui_main.c). */ +#ifndef SD_UI_DPI_H +#define SD_UI_DPI_H + +#include + +#define UI_DPI_BASE 96u + +/* value (in 96-DPI pixels) scaled to dpi, rounded half away from zero (MulDiv semantics). dpi 0 is 96; + dpi is clamped to [48, 960] so a bad value cannot overflow. */ +int ui_dpi_scale(int value, unsigned dpi); + +/* Font point size to a LOGFONT lfHeight at dpi (negative: character height). */ +int ui_dpi_font_height(int points, unsigned dpi); + +/* Resolves the DPI APIs and reads the system DPI. Idempotent; call from the UI thread at startup. */ +void ui_dpi_init(void); + +/* System DPI, fixed for the session on XP..8 (GetDeviceCaps(LOGPIXELSX)). */ +unsigned ui_dpi_system(void); + +/* DPI of a window: GetDpiForWindow when available, else per-monitor (8.1+), else the system DPI. */ +unsigned ui_dpi_window(HWND hwnd); + +#endif /* SD_UI_DPI_H */ diff --git a/apps/shelldrive-ui/ui_editor.c b/apps/shelldrive-ui/ui_editor.c new file mode 100644 index 0000000..e3896cd --- /dev/null +++ b/apps/shelldrive-ui/ui_editor.c @@ -0,0 +1,959 @@ +/* shelldrive-ui: the profile editor, the ad-hoc mount dialog and the confirmation dialog (03 §7.1, §7.5). + * + * The editor is a property sheet (PropertySheetW, PSP_DLGINDIRECT pages, 03 §7.1) with five pages: General, + * Authentication, Connection, Mount and Security. The pages are built in memory with ui_dlg (the generator the + * prompt dialogs use), so every label is measured and nothing is clipped in German or French. Every page is + * created up front (PSP_PREMATURE), so all of them can be read when the sheet is confirmed. + * + * Values travel as text through the pure model (ui_profile_model.h): ui_pform_get fills a control and + * ui_pform_set reads it back into the working copy. The tables k_pages say which field lives in which control; + * a control missing from a page (the name in ad-hoc mode) is skipped. Validation runs on every OK: the errors + * of each page are shown on that page, and the first page with an error is brought to the front. + * + * Ad-hoc mount (UI_EDIT_ADHOC): the same pages without the name; the caller sends the connection to the agent + * (ui_mount_params_json). A drop-down list grows while it is open (combo_drop): the template height of a combo + * box is its closed height. */ +#include +#include +#include +#include +#include +#include +#include +#include + +#include "sd_base/sd_mem.h" +#include "sd_l10n/sd_l10n.h" +#include "sd_l10n_ui_ids.h" +#include "sd_policy/sd_policy.h" +#include "ui_dlg.h" +#include "ui_int.h" +#include "ui_measure.h" +#include "ui_profile_model.h" + +#define ED_WIDTH 300 /* dialog units; every page of the sheet has this width */ +#define ED_PAGES 5 +#define ED_ERR_LINES 3 /* lines of the error box of a page */ +#define ED_CTL(f) (1000 + (int)(f)) /* control id of a field (edit, check box or combo box) */ +#define ED_LBL(f) (2000 + (int)(f)) /* static label of a field */ +#define ED_ID_BROWSE 1500 +#define ED_ID_ERR 1501 +#define ED_ID_INTRO 1502 +#define ED_ID_LEGACY 1503 +#define ED_ID_WMNOTE 1504 +#define ED_ERR_CHARS 1024 /* wide characters of the error text of one page */ + +enum { PG_GENERAL = 0, PG_AUTH, PG_CONN, PG_MOUNT, PG_SECURITY }; + +typedef enum ctl_kind { K_EDIT = 0, K_CHECK, K_ENUM, K_TARGET } ctl_kind; + +/* A value list of a combo box: the stored values in display order, and the catalog label of each. */ +typedef struct ed_enum { + ui_pfield f; + int n; + const int *values; + const sd_msg_id *labels; /* NULL: the number itself is shown */ +} ed_enum; + +typedef struct ctl_desc { + ui_pfield f; + ctl_kind kind; + const ed_enum *en; +} ctl_desc; + +typedef struct ed_ctx ed_ctx; + +typedef struct ed_page { + ed_ctx *ed; + int index; + HWND hwnd; /* set by WM_INITDIALOG */ +} ed_page; + +struct ed_ctx { + sd_profile work; /* the working copy; the caller's profile is replaced on OK */ + ui_edit_mode mode; + ed_page pages[ED_PAGES]; + wchar_t drives[UI_DRIVE_CANDIDATES][3]; + size_t ndrives; + wchar_t target_vals[UI_DRIVE_CANDIDATES + 2][8]; /* data of the target box: "auto", "X:" */ + const wchar_t *target_items[UI_DRIVE_CANDIDATES + 2]; + size_t ntarget; + ui_pferr parse_errs[UI_PF_COUNT]; /* values that did not parse on the last read */ + size_t nparse; + int wm_shown; /* the write mode the Writes list showed (effective default for an unset profile) */ + int wm_locked; /* ForceWriteThrough: the Writes list is read-only */ +}; + +/* ---- value lists (the stored values of each combo box, in display order) ---- */ + +static const int k_agent_v[] = { SD_PROFILE_AGENT_AUTO, SD_PROFILE_AGENT_PAGEANT, SD_PROFILE_AGENT_OPENSSH, SD_PROFILE_AGENT_NONE }; +static const sd_msg_id k_agent_l[] = { SD_MSG_UI_AGENT_AUTO, SD_MSG_UI_AGENT_PAGEANT, SD_MSG_UI_AGENT_OPENSSH, SD_MSG_UI_AGENT_NONE }; +static const ed_enum k_agent = { UI_PF_AGENT, 4, k_agent_v, k_agent_l }; + +static const int k_idonly_v[] = { SD_PROFILE_UNSET, 1, 0 }; +static const sd_msg_id k_idonly_l[] = { SD_MSG_UI_IDONLY_UNSET, SD_MSG_UI_IDONLY_YES, SD_MSG_UI_IDONLY_NO }; +static const ed_enum k_idonly = { UI_PF_IDENTITIES_ONLY, 3, k_idonly_v, k_idonly_l }; + +static const int k_proxy_v[] = { SD_PROFILE_PROXY_NONE, SD_PROFILE_PROXY_HTTP, SD_PROFILE_PROXY_SOCKS5, SD_PROFILE_PROXY_SYSTEM }; +static const sd_msg_id k_proxy_l[] = { SD_MSG_UI_PROXY_NONE, SD_MSG_UI_PROXY_HTTP, SD_MSG_UI_PROXY_SOCKS5, SD_MSG_UI_PROXY_SYSTEM }; +static const ed_enum k_proxy = { UI_PF_PROXY_KIND, 4, k_proxy_v, k_proxy_l }; + +static const int k_pauth_v[] = { SD_PROFILE_PROXY_AUTH_AUTO, SD_PROFILE_PROXY_AUTH_BASIC, SD_PROFILE_PROXY_AUTH_NEGOTIATE, SD_PROFILE_PROXY_AUTH_NONE }; +static const sd_msg_id k_pauth_l[] = { SD_MSG_UI_PAUTH_AUTO, SD_MSG_UI_PAUTH_BASIC, SD_MSG_UI_PAUTH_NEGOTIATE, SD_MSG_UI_PAUTH_NONE }; +static const ed_enum k_pauth = { UI_PF_PROXY_AUTH, 4, k_pauth_v, k_pauth_l }; + +static const int k_conn_v[] = { 1, 2, 3, 4 }; +static const ed_enum k_conn = { UI_PF_CONNECTIONS, 4, k_conn_v, NULL }; + +static const int k_backend_v[] = { SD_PROFILE_BACKEND_AUTO, SD_PROFILE_BACKEND_DOKANY, SD_PROFILE_BACKEND_WEBDAV }; +static const sd_msg_id k_backend_l[] = { SD_MSG_UI_BACKEND_AUTO, SD_MSG_UI_BACKEND_DOKANY, SD_MSG_UI_BACKEND_WEBDAV }; +static const ed_enum k_backend = { UI_PF_BACKEND, 3, k_backend_v, k_backend_l }; + +static const int k_write_v[] = { SD_PROFILE_WRITE_THROUGH, SD_PROFILE_WRITE_BEHIND }; +static const sd_msg_id k_write_l[] = { SD_MSG_UI_WRITE_THROUGH, SD_MSG_UI_WRITE_BEHIND }; +static const ed_enum k_write = { UI_PF_WRITE_MODE, 2, k_write_v, k_write_l }; + +static const int k_symlinks_v[] = { SD_PROFILE_SYMLINKS_FOLLOW_INSIDE, SD_PROFILE_SYMLINKS_FOLLOW, SD_PROFILE_SYMLINKS_HIDE }; +static const sd_msg_id k_symlinks_l[] = { SD_MSG_UI_SYMLINKS_INSIDE, SD_MSG_UI_SYMLINKS_FOLLOW, SD_MSG_UI_SYMLINKS_HIDE }; +static const ed_enum k_symlinks = { UI_PF_SYMLINKS, 3, k_symlinks_v, k_symlinks_l }; + +static const int k_legacy_v[] = { SD_PROFILE_LEGACY_ALLOW, SD_PROFILE_LEGACY_WARN, SD_PROFILE_LEGACY_ASK, SD_PROFILE_LEGACY_REFUSE }; +static const sd_msg_id k_legacy_l[] = { SD_MSG_UI_LEGACY_ALLOW, SD_MSG_UI_LEGACY_WARN, SD_MSG_UI_LEGACY_ASK, SD_MSG_UI_LEGACY_REFUSE }; +static const ed_enum k_legacy = { UI_PF_LEGACY, 4, k_legacy_v, k_legacy_l }; + +static const int k_hku_v[] = { SD_PROFILE_HKU_ASK, SD_PROFILE_HKU_YES, SD_PROFILE_HKU_NO }; +static const sd_msg_id k_hku_l[] = { SD_MSG_UI_HKU_ASK, SD_MSG_UI_HKU_SAVE, SD_MSG_UI_HKU_IGNORE }; +static const ed_enum k_hku = { UI_PF_UPDATE_HOST_KEYS, 3, k_hku_v, k_hku_l }; + +/* ---- which control holds which field, per page ---- */ + +static const ctl_desc k_general[] = { + { UI_PF_NAME, K_EDIT, NULL }, { UI_PF_HOST, K_EDIT, NULL }, { UI_PF_PORT, K_EDIT, NULL }, + { UI_PF_USER, K_EDIT, NULL }, { UI_PF_REMOTE_PATH, K_EDIT, NULL }, +}; +static const ctl_desc k_auth[] = { + { UI_PF_METHOD_PUBLICKEY, K_CHECK, NULL }, { UI_PF_METHOD_KBDINT, K_CHECK, NULL }, + { UI_PF_METHOD_PASSWORD, K_CHECK, NULL }, { UI_PF_IDENTITY, K_EDIT, NULL }, + { UI_PF_CERTIFICATE, K_EDIT, NULL }, { UI_PF_AGENT, K_ENUM, &k_agent }, + { UI_PF_IDENTITIES_ONLY, K_ENUM, &k_idonly }, { UI_PF_SAVE_PASSWORD, K_CHECK, NULL }, + { UI_PF_CACHE_SECRETS, K_CHECK, NULL }, +}; +static const ctl_desc k_conn_page[] = { + { UI_PF_JUMP, K_EDIT, NULL }, { UI_PF_PROXY_KIND, K_ENUM, &k_proxy }, { UI_PF_PROXY_HOST, K_EDIT, NULL }, + { UI_PF_PROXY_PORT, K_EDIT, NULL }, { UI_PF_PROXY_USER, K_EDIT, NULL }, { UI_PF_PROXY_AUTH, K_ENUM, &k_pauth }, + { UI_PF_CONNECTIONS, K_ENUM, &k_conn }, { UI_PF_KEEPALIVE, K_EDIT, NULL }, + { UI_PF_CONNECT_TIMEOUT, K_EDIT, NULL }, +}; +static const ctl_desc k_mount[] = { + { UI_PF_TARGET, K_TARGET, NULL }, { UI_PF_BACKEND, K_ENUM, &k_backend }, { UI_PF_READ_ONLY, K_CHECK, NULL }, + { UI_PF_WRITE_MODE, K_ENUM, &k_write }, { UI_PF_SYMLINKS, K_ENUM, &k_symlinks }, { UI_PF_LABEL, K_EDIT, NULL }, + { UI_PF_OFFLINE_WAIT, K_EDIT, NULL }, { UI_PF_NETWORK_LOCATION, K_CHECK, NULL }, + { UI_PF_DISK_CACHE, K_EDIT, NULL }, +}; +static const ctl_desc k_security[] = { + { UI_PF_LEGACY, K_ENUM, &k_legacy }, { UI_PF_HOST_KEY_ALIAS, K_EDIT, NULL }, + { UI_PF_UPDATE_HOST_KEYS, K_ENUM, &k_hku }, { UI_PF_REFUSE_TERRAPIN, K_CHECK, NULL }, +}; + +static const struct { const ctl_desc *d; size_t n; } k_pages[ED_PAGES] = { + { k_general, SD_COUNTOF(k_general) }, { k_auth, SD_COUNTOF(k_auth) }, { k_conn_page, SD_COUNTOF(k_conn_page) }, + { k_mount, SD_COUNTOF(k_mount) }, { k_security, SD_COUNTOF(k_security) }, +}; + +/* The page that shows the error of a field; general errors and fields without a page go to General. */ +static int page_of(int f) +{ + switch (f) { + case UI_PF_METHOD_PUBLICKEY: case UI_PF_METHOD_KBDINT: case UI_PF_METHOD_PASSWORD: case UI_PF_IDENTITY: + case UI_PF_CERTIFICATE: case UI_PF_AGENT: case UI_PF_IDENTITIES_ONLY: case UI_PF_SAVE_PASSWORD: + case UI_PF_CACHE_SECRETS: + return PG_AUTH; + case UI_PF_JUMP: case UI_PF_PROXY_KIND: case UI_PF_PROXY_HOST: case UI_PF_PROXY_PORT: case UI_PF_PROXY_USER: + case UI_PF_PROXY_AUTH: case UI_PF_CONNECTIONS: case UI_PF_KEEPALIVE: case UI_PF_CONNECT_TIMEOUT: + return PG_CONN; + case UI_PF_TARGET: case UI_PF_BACKEND: case UI_PF_READ_ONLY: case UI_PF_WRITE_MODE: case UI_PF_SYMLINKS: + case UI_PF_LABEL: case UI_PF_OFFLINE_WAIT: case UI_PF_NETWORK_LOCATION: case UI_PF_DISK_CACHE: + return PG_MOUNT; + case UI_PF_LEGACY: case UI_PF_HOST_KEY_ALIAS: case UI_PF_UPDATE_HOST_KEYS: case UI_PF_REFUSE_TERRAPIN: + return PG_SECURITY; + default: + return PG_GENERAL; + } +} + +/* ---- text conversion ---- */ + +static wchar_t *to_wide(const char *s) +{ + int n; + wchar_t *w; + if (s == NULL) + s = ""; + n = MultiByteToWideChar(CP_UTF8, 0, s, -1, NULL, 0); + if (n <= 0) + return NULL; + w = (wchar_t *)sd_alloc((size_t)n * sizeof(wchar_t)); + if (w != NULL && MultiByteToWideChar(CP_UTF8, 0, s, -1, w, n) == 0) { + sd_free(w); + return NULL; + } + return w; +} + +/* UTF-8 text of a control. SD_E_INVALID_PARAM when it does not fit in cap. */ +static sd_err get_text(HWND ctl, char *out, size_t cap) +{ + int n = GetWindowTextLengthW(ctl); + wchar_t *w; + int k, u; + if (n < 0) + return SD_E_INVALID_PARAM; + w = (wchar_t *)sd_alloc((size_t)n * sizeof(wchar_t) + sizeof(wchar_t)); + if (w == NULL) + return SD_E_NO_MEMORY; + k = GetWindowTextW(ctl, w, n + 1); + if (k < 0) + k = 0; + w[k] = 0; + u = WideCharToMultiByte(CP_UTF8, 0, w, -1, out, (int)cap, NULL, NULL); + sd_free(w); + return u > 0 ? SD_OK : SD_E_INVALID_PARAM; +} + +static void set_text(HWND ctl, const char *utf8) +{ + wchar_t *w = to_wide(utf8); + if (w != NULL) { + SetWindowTextW(ctl, w); + sd_free(w); + } +} + +/* ---- drop-down lists ---- */ + +/* The closed height of a combo box is kept in a window property. While the list is open the box is as tall as + the list (up to 10 items), so the list is not clipped by its own box. */ +static void combo_drop(HWND combo, int open) +{ + RECT rc; + POINT pt; + HWND parent = GetParent(combo); + int closed = (int)(INT_PTR)GetPropW(combo, L"sd.closed"); + int items = (int)SendMessageW(combo, CB_GETCOUNT, 0, 0); + int ih = (int)SendMessageW(combo, CB_GETITEMHEIGHT, 0, 0); + int h = closed, k; + if (closed <= 0 || parent == NULL || ih <= 0) + return; + if (open) { + k = items < 1 ? 1 : (items > 10 ? 10 : items); + h = closed + k * ih + 4; + } + GetWindowRect(combo, &rc); + pt.x = rc.left; + pt.y = rc.top; + ScreenToClient(parent, &pt); + SetWindowPos(combo, NULL, pt.x, pt.y, rc.right - rc.left, h, SWP_NOZORDER | SWP_NOACTIVATE); +} + +static void remember_closed_height(HWND combo) +{ + RECT rc; + if (GetWindowRect(combo, &rc)) + SetPropW(combo, L"sd.closed", (HANDLE)(INT_PTR)(rc.bottom - rc.top)); +} + +static void fill_enum(HWND combo, const ed_enum *en, long want) +{ + int i, sel = 0; + SendMessageW(combo, CB_RESETCONTENT, 0, 0); + for (i = 0; i < en->n; i++) { + if (en->labels != NULL) { + SendMessageW(combo, CB_ADDSTRING, 0, (LPARAM)sd_l10n_get(en->labels[i])); + } else { + wchar_t num[16]; + wsprintfW(num, L"%d", en->values[i]); + SendMessageW(combo, CB_ADDSTRING, 0, (LPARAM)num); + } + if ((long)en->values[i] == want) + sel = i; + } + SendMessageW(combo, CB_SETCURSEL, (WPARAM)sel, 0); +} + +static int enum_value(HWND combo, const ed_enum *en, int *out) +{ + int sel = (int)SendMessageW(combo, CB_GETCURSEL, 0, 0); + if (sel < 0 || sel >= en->n) + return 0; + *out = en->values[sel]; + return 1; +} + +/* ---- target (drive letter) list: "auto", the free letters, and the current target when it is not free ---- */ + +static void fill_target(ed_ctx *ed, HWND combo) +{ + size_t i; + char cur[16]; + int sel = 0; + SendMessageW(combo, CB_RESETCONTENT, 0, 0); + ed->ntarget = 0; + wcscpy_s(ed->target_vals[0], SD_COUNTOF(ed->target_vals[0]), L"auto"); + ed->target_items[0] = ed->target_vals[0]; + ed->ntarget = 1; + for (i = 0; i < ed->ndrives && ed->ntarget < SD_COUNTOF(ed->target_vals); i++) { + wcscpy_s(ed->target_vals[ed->ntarget], SD_COUNTOF(ed->target_vals[0]), ed->drives[i]); + ed->target_items[ed->ntarget] = ed->target_vals[ed->ntarget]; + ed->ntarget++; + } + if (ui_pform_get(&ed->work, UI_PF_TARGET, cur, sizeof(cur)) != SD_OK) + strcpy_s(cur, sizeof(cur), "auto"); + if (strcmp(cur, "auto") != 0) { + int found = 0; + for (i = 1; i < ed->ntarget; i++) { + char one[16]; + if (WideCharToMultiByte(CP_UTF8, 0, ed->target_items[i], -1, one, sizeof(one), NULL, NULL) > 0 && + strcmp(one, cur) == 0) { + found = 1; + break; + } + } + if (!found && ed->ntarget < SD_COUNTOF(ed->target_vals)) { + wchar_t *w = to_wide(cur); + if (w != NULL) { + wcsncpy_s(ed->target_vals[ed->ntarget], SD_COUNTOF(ed->target_vals[0]), w, _TRUNCATE); + ed->target_items[ed->ntarget] = ed->target_vals[ed->ntarget]; + ed->ntarget++; + sd_free(w); + } + } + } + for (i = 0; i < ed->ntarget; i++) { + const wchar_t *label = i == 0 ? sd_l10n_get(SD_MSG_UI_TARGET_AUTO) : ed->target_items[i]; + int k = (int)SendMessageW(combo, CB_ADDSTRING, 0, (LPARAM)label); + SendMessageW(combo, CB_SETITEMDATA, (WPARAM)k, (LPARAM)ed->target_items[i]); + if (i > 0 && WideCharToMultiByte(CP_UTF8, 0, ed->target_items[i], -1, NULL, 0, NULL, NULL) > 0) { + char one[16]; + if (WideCharToMultiByte(CP_UTF8, 0, ed->target_items[i], -1, one, sizeof(one), NULL, NULL) > 0 && + strcmp(one, cur) == 0) + sel = k; + } + } + SendMessageW(combo, CB_SETCURSEL, (WPARAM)sel, 0); +} + +/* ---- reading and writing the pages ---- */ + +static void write_page(ed_ctx *ed, int pg, HWND page) +{ + size_t i; + const ctl_desc *d = k_pages[pg].d; + for (i = 0; i < k_pages[pg].n; i++) { + HWND c = GetDlgItem(page, ED_CTL(d[i].f)); + char text[UI_PF_TEXT_MAX]; + if (c == NULL) + continue; + switch (d[i].kind) { + case K_EDIT: + if (ui_pform_get(&ed->work, d[i].f, text, sizeof(text)) == SD_OK) + set_text(c, text); + break; + case K_CHECK: + if (ui_pform_get(&ed->work, d[i].f, text, sizeof(text)) == SD_OK) + SendMessageW(c, BM_SETCHECK, atoi(text) ? BST_CHECKED : BST_UNCHECKED, 0); + break; + case K_ENUM: + if (d[i].f == UI_PF_WRITE_MODE) { + DWORD fv = 0; + int force = sd_policy_get_dword(SD_POLICY_FORCEWRITETHROUGH, &fv) && fv != 0; + ed->wm_shown = ui_pform_write_mode_shown(&ed->work, &g_app.settings, force, &ed->wm_locked); + fill_enum(c, d[i].en, ed->wm_shown); + EnableWindow(c, ed->wm_locked ? FALSE : TRUE); + } else if (ui_pform_get(&ed->work, d[i].f, text, sizeof(text)) == SD_OK) { + fill_enum(c, d[i].en, atol(text)); + } + remember_closed_height(c); + break; + case K_TARGET: + fill_target(ed, c); + remember_closed_height(c); + break; + } + } +} + +/* Reads the controls of one page into the working copy. A value that does not parse is recorded. */ +static void read_page(ed_ctx *ed, int pg, HWND page) +{ + size_t i; + const ctl_desc *d = k_pages[pg].d; + if (page == NULL) + return; + for (i = 0; i < k_pages[pg].n; i++) { + HWND c = GetDlgItem(page, ED_CTL(d[i].f)); + char text[UI_PF_TEXT_MAX]; + int value = 0; + sd_err e = SD_OK; + if (c == NULL) + continue; + switch (d[i].kind) { + case K_EDIT: + e = get_text(c, text, sizeof(text)); + if (e == SD_OK) + e = ui_pform_set(&ed->work, d[i].f, text); + break; + case K_CHECK: + sprintf_s(text, sizeof(text), "%d", SendMessageW(c, BM_GETCHECK, 0, 0) == BST_CHECKED ? 1 : 0); + e = ui_pform_set(&ed->work, d[i].f, text); + break; + case K_ENUM: + if (!enum_value(c, d[i].en, &value)) { + e = SD_E_INVALID_PARAM; + } else if (d[i].f == UI_PF_WRITE_MODE) { + ui_pform_write_mode_commit(&ed->work, ed->wm_shown, value, ed->wm_locked); + } else { + sprintf_s(text, sizeof(text), "%d", value); + e = ui_pform_set(&ed->work, d[i].f, text); + } + break; + case K_TARGET: { + int sel = (int)SendMessageW(c, CB_GETCURSEL, 0, 0); + const wchar_t *v = sel >= 0 ? (const wchar_t *)SendMessageW(c, CB_GETITEMDATA, (WPARAM)sel, 0) : NULL; + char narrow[16]; + if (v == NULL || WideCharToMultiByte(CP_UTF8, 0, v, -1, narrow, sizeof(narrow), NULL, NULL) == 0) + e = SD_E_INVALID_PARAM; + else + e = ui_pform_set(&ed->work, d[i].f, narrow); + break; + } + } + if (e != SD_OK && ed->nparse < SD_COUNTOF(ed->parse_errs)) { + ed->parse_errs[ed->nparse].field = (int)d[i].f; + if (d[i].kind == K_EDIT && ui_pform_is_text(d[i].f)) + ed->parse_errs[ed->nparse].code = UI_PERR_OTHER; + else if (d[i].f == UI_PF_PORT || d[i].f == UI_PF_PROXY_PORT) + ed->parse_errs[ed->nparse].code = UI_PERR_PORT; + else + ed->parse_errs[ed->nparse].code = UI_PERR_NUMBER; + ed->nparse++; + } + } +} + +/* ---- validation messages ---- */ + +static sd_msg_id err_message(ui_perr code) +{ + switch (code) { + case UI_PERR_NAME: return SD_MSG_UI_ERROR_NAME; + case UI_PERR_HOST: return SD_MSG_UI_ERROR_HOST; + case UI_PERR_PORT: return SD_MSG_UI_ERROR_PORT; + case UI_PERR_NUMBER: return SD_MSG_UI_ERROR_NUMBER; + case UI_PERR_CONNECTIONS: return SD_MSG_UI_ERROR_CONNECTIONS; + case UI_PERR_METHODS: return SD_MSG_UI_ERROR_METHODS; + case UI_PERR_PROXY_HOST: return SD_MSG_UI_ERROR_PROXY_HOST; + case UI_PERR_PROXY_PORT: return SD_MSG_UI_ERROR_PROXY_PORT; + default: return SD_MSG_UI_ERROR_OTHER; + } +} + +/* Shows the error lines on every page: each page lists the errors of its own fields. */ +static void show_errors(ed_ctx *ed, const ui_pferr *errs, size_t n) +{ + wchar_t *text[ED_PAGES]; + size_t used[ED_PAGES]; + size_t pg, i; + for (pg = 0; pg < ED_PAGES; pg++) { + text[pg] = (wchar_t *)sd_calloc(ED_ERR_CHARS, sizeof(wchar_t)); + used[pg] = 0; + } + for (i = 0; i < n; i++) { + int pgi = page_of(errs[i].field); + const wchar_t *msg = sd_l10n_get(err_message(errs[i].code)); + size_t add = wcslen(msg); + if (text[pgi] == NULL || used[pgi] + add + 2 >= ED_ERR_CHARS) + continue; + if (used[pgi] > 0) { + wcscat_s(text[pgi], ED_ERR_CHARS, L"\r\n"); + used[pgi] += 2; + } + wcscat_s(text[pgi], ED_ERR_CHARS, msg); + used[pgi] += add; + } + for (pg = 0; pg < ED_PAGES; pg++) { + HWND err = ed->pages[pg].hwnd ? GetDlgItem(ed->pages[pg].hwnd, ED_ID_ERR) : NULL; + if (err != NULL) + SetWindowTextW(err, text[pg] ? text[pg] : L""); + sd_free(text[pg]); + } +} + +/* Reads every page, validates the whole profile and shows the errors. Returns the number of errors; the + working copy holds what the controls say. */ +static size_t apply_all(ed_ctx *ed, ui_pferr *errs, size_t cap) +{ + ui_pferr all[UI_PF_COUNT + 24]; + size_t pg, i, total = 0, n; + ed->nparse = 0; + for (pg = 0; pg < ED_PAGES; pg++) + read_page(ed, (int)pg, ed->pages[pg].hwnd); + if (ed->mode == UI_EDIT_ADHOC) + strcpy_s(ed->work.name, sizeof(ed->work.name), ed->work.host); /* the ad-hoc connection is named by its server */ + for (i = 0; i < ed->nparse && total < SD_COUNTOF(all); i++) + all[total++] = ed->parse_errs[i]; + n = ui_pform_validate(&ed->work, all + total, SD_COUNTOF(all) - total); + total += n; + if (total > SD_COUNTOF(all)) + total = SD_COUNTOF(all); + if (errs != NULL) + for (i = 0; i < total && i < cap; i++) + errs[i] = all[i]; + show_errors(ed, all, total); + return total; +} + +/* ---- the Browse button of the key file ---- */ + +static void browse_identity(ed_ctx *ed, HWND page) +{ + wchar_t path[MAX_PATH * 3]; + wchar_t filter[64]; + OPENFILENAMEW ofn; + HWND edit = GetDlgItem(page, ED_CTL(UI_PF_IDENTITY)); + size_t n; + (void)ed; + if (edit == NULL) + return; + GetWindowTextW(edit, path, SD_COUNTOF(path)); + memset(filter, 0, sizeof(filter)); + wcscpy_s(filter, SD_COUNTOF(filter), sd_l10n_get(SD_MSG_UI_FILE_ANY)); + n = wcslen(filter); + wcscpy_s(filter + n + 1, SD_COUNTOF(filter) - n - 1, L"*.*"); + memset(&ofn, 0, sizeof(ofn)); + ofn.lStructSize = sizeof(ofn); + ofn.hwndOwner = page; + ofn.lpstrFilter = filter; + ofn.lpstrFile = path; + ofn.nMaxFile = SD_COUNTOF(path); + ofn.Flags = OFN_FILEMUSTEXIST | OFN_PATHMUSTEXIST | OFN_NOCHANGEDIR; + if (GetOpenFileNameW(&ofn)) + SetWindowTextW(edit, path); +} + +/* ---- the page procedure ---- */ + +static INT_PTR CALLBACK page_proc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) +{ + switch (msg) { + case WM_INITDIALOG: { + const PROPSHEETPAGEW *psp = (const PROPSHEETPAGEW *)lp; + ed_page *pg = psp ? (ed_page *)psp->lParam : NULL; + if (pg == NULL) + return FALSE; + SetWindowLongPtrW(hwnd, DWLP_USER, (LONG_PTR)pg); + pg->hwnd = hwnd; + write_page(pg->ed, pg->index, hwnd); + return TRUE; + } + case WM_NOTIFY: { + const NMHDR *nm = (const NMHDR *)lp; + ed_page *pg = (ed_page *)GetWindowLongPtrW(hwnd, DWLP_USER); + if (pg == NULL) + break; + if (nm->code == PSN_KILLACTIVE) { + SetWindowLongPtrW(hwnd, DWLP_MSGRESULT, FALSE); + return TRUE; + } + if (nm->code == PSN_APPLY) { + /* every page reads its controls and validates the whole profile; this page refuses the OK when one of its + own fields has an error, which also brings it to the front */ + ui_pferr errs[UI_PF_COUNT + 24]; + size_t i, total = apply_all(pg->ed, errs, SD_COUNTOF(errs)); + int mine = 0; + for (i = 0; i < total && i < SD_COUNTOF(errs); i++) + if (page_of(errs[i].field) == pg->index) + mine = 1; + SetWindowLongPtrW(hwnd, DWLP_MSGRESULT, mine ? PSNRET_INVALID : PSNRET_NOERROR); + return TRUE; + } + break; + } + case WM_COMMAND: { + WORD id = LOWORD(wp), code = HIWORD(wp); + ed_page *pg = (ed_page *)GetWindowLongPtrW(hwnd, DWLP_USER); + if (pg == NULL) + break; + if (id == ED_ID_BROWSE && code == BN_CLICKED) { + browse_identity(pg->ed, hwnd); + return TRUE; + } + if (code == CBN_DROPDOWN || code == CBN_CLOSEUP) { + combo_drop((HWND)lp, code == CBN_DROPDOWN); + return TRUE; + } + break; + } + default: + break; + } + return FALSE; +} + +/* ---- page templates ---- */ + +static void add_label(ui_dlg *d, ui_pfield f, sd_msg_id msg) +{ + ui_dlg_text(d, (WORD)ED_LBL(f), sd_l10n_get(msg), 0); +} + +static void add_edit(ui_dlg *d, ui_pfield f, sd_msg_id msg, DWORD extra) +{ + add_label(d, f, msg); + ui_dlg_edit(d, (WORD)ED_CTL(f), extra); +} + +static void add_enum(ui_dlg *d, ui_pfield f, sd_msg_id msg) +{ + add_label(d, f, msg); + ui_dlg_combo(d, (WORD)ED_CTL(f)); +} + +static void add_check(ui_dlg *d, ui_pfield f, sd_msg_id msg) +{ + ui_dlg_check(d, (WORD)ED_CTL(f), sd_l10n_get(msg)); +} + +static void add_errors(ui_dlg *d) +{ + ui_dlg_multiline(d, ED_ID_ERR, L"", ED_ERR_LINES); +} + +static void add_page_general(ui_dlg *d, ed_ctx *ed) +{ + ui_dlg_text(d, ED_ID_INTRO, sd_l10n_get(SD_MSG_UI_GENERAL_INTRO), 0); + if (ed->mode != UI_EDIT_ADHOC) + add_edit(d, UI_PF_NAME, SD_MSG_UI_GENERAL_NAME, 0); + add_edit(d, UI_PF_HOST, SD_MSG_UI_GENERAL_HOST, 0); + add_edit(d, UI_PF_PORT, SD_MSG_UI_GENERAL_PORT, ES_NUMBER); + add_edit(d, UI_PF_USER, SD_MSG_UI_GENERAL_USER, 0); + add_edit(d, UI_PF_REMOTE_PATH, SD_MSG_UI_GENERAL_REMOTE_PATH, 0); + add_errors(d); +} + +static void add_page_auth(ui_dlg *d) +{ + static const WORD ids[] = { ED_ID_BROWSE }; + const wchar_t *texts[1]; + add_check(d, UI_PF_METHOD_PUBLICKEY, SD_MSG_UI_AUTH_METHOD_PUBLICKEY); + add_check(d, UI_PF_METHOD_KBDINT, SD_MSG_UI_AUTH_METHOD_KBDINT); + add_check(d, UI_PF_METHOD_PASSWORD, SD_MSG_UI_AUTH_METHOD_PASSWORD); + add_edit(d, UI_PF_IDENTITY, SD_MSG_UI_AUTH_IDENTITY, 0); + texts[0] = sd_l10n_get(SD_MSG_UI_AUTH_BROWSE); + ui_dlg_buttons(d, ids, texts, 1, 0); + add_edit(d, UI_PF_CERTIFICATE, SD_MSG_UI_AUTH_CERTIFICATE, 0); + add_enum(d, UI_PF_AGENT, SD_MSG_UI_AUTH_AGENT); + add_enum(d, UI_PF_IDENTITIES_ONLY, SD_MSG_UI_AUTH_IDENTITIES_ONLY); + add_check(d, UI_PF_SAVE_PASSWORD, SD_MSG_UI_AUTH_SAVE_PASSWORD); + add_check(d, UI_PF_CACHE_SECRETS, SD_MSG_UI_AUTH_CACHE_SECRETS); + add_errors(d); +} + +static void add_page_conn(ui_dlg *d) +{ + ui_dlg_text(d, ED_ID_INTRO, sd_l10n_get(SD_MSG_UI_CONN_INTRO), 0); + add_edit(d, UI_PF_JUMP, SD_MSG_UI_CONN_JUMP, 0); + add_enum(d, UI_PF_PROXY_KIND, SD_MSG_UI_CONN_PROXY_KIND); + add_edit(d, UI_PF_PROXY_HOST, SD_MSG_UI_CONN_PROXY_HOST, 0); + add_edit(d, UI_PF_PROXY_PORT, SD_MSG_UI_CONN_PROXY_PORT, ES_NUMBER); + add_edit(d, UI_PF_PROXY_USER, SD_MSG_UI_CONN_PROXY_USER, 0); + add_enum(d, UI_PF_PROXY_AUTH, SD_MSG_UI_CONN_PROXY_AUTH); + add_enum(d, UI_PF_CONNECTIONS, SD_MSG_UI_CONN_CONNECTIONS); + add_edit(d, UI_PF_KEEPALIVE, SD_MSG_UI_CONN_KEEPALIVE, ES_NUMBER); + add_edit(d, UI_PF_CONNECT_TIMEOUT, SD_MSG_UI_CONN_CONNECT_TIMEOUT, ES_NUMBER); + add_errors(d); +} + +static void add_page_mount(ui_dlg *d) +{ + add_enum(d, UI_PF_TARGET, SD_MSG_UI_MOUNT_TARGET); + add_enum(d, UI_PF_BACKEND, SD_MSG_UI_MOUNT_BACKEND); + add_check(d, UI_PF_READ_ONLY, SD_MSG_UI_MOUNT_READ_ONLY); + add_enum(d, UI_PF_WRITE_MODE, SD_MSG_UI_MOUNT_WRITE_MODE); + { + DWORD fv = 0; + if (sd_policy_get_dword(SD_POLICY_FORCEWRITETHROUGH, &fv) && fv != 0) + ui_dlg_text(d, ED_ID_WMNOTE, sd_l10n_get(SD_MSG_UI_SETTINGS_UPDATE_MANAGED), 0); + } + add_enum(d, UI_PF_SYMLINKS, SD_MSG_UI_MOUNT_SYMLINKS); + add_edit(d, UI_PF_LABEL, SD_MSG_UI_MOUNT_LABEL, 0); + add_edit(d, UI_PF_OFFLINE_WAIT, SD_MSG_UI_MOUNT_OFFLINE_WAIT, ES_NUMBER); + add_check(d, UI_PF_NETWORK_LOCATION, SD_MSG_UI_MOUNT_NETWORK_LOCATION); + add_edit(d, UI_PF_DISK_CACHE, SD_MSG_UI_MOUNT_DISK_CACHE, ES_NUMBER); + add_errors(d); +} + +static void add_page_security(ui_dlg *d) +{ + ui_dlg_text(d, ED_ID_LEGACY, sd_l10n_get(SD_MSG_UI_SECURITY_LEGACY_TEXT), 0); + add_enum(d, UI_PF_LEGACY, SD_MSG_UI_SECURITY_LEGACY); + add_edit(d, UI_PF_HOST_KEY_ALIAS, SD_MSG_UI_SECURITY_HOST_KEY_ALIAS, 0); + add_enum(d, UI_PF_UPDATE_HOST_KEYS, SD_MSG_UI_SECURITY_UPDATE_HOST_KEYS); + add_check(d, UI_PF_REFUSE_TERRAPIN, SD_MSG_UI_SECURITY_REFUSE_TERRAPIN); + add_errors(d); +} + +/* Builds the five page templates. All pages get the height of the tallest one, so the sheet is one size. */ +static sd_err build_pages(ed_ctx *ed, unsigned char *blob[ED_PAGES], size_t len[ED_PAGES]) +{ + static const sd_msg_id titles[ED_PAGES] = { SD_MSG_UI_TAB_GENERAL, SD_MSG_UI_TAB_AUTH, SD_MSG_UI_TAB_CONN, + SD_MSG_UI_TAB_MOUNT, SD_MSG_UI_TAB_SECURITY }; + wchar_t face[UI_DLG_FACE_MAX]; + WORD points = 8; + int dpi = 96, tallest = 0, pg; + ui_measure m; + ui_dlg *d[ED_PAGES]; + sd_err e = SD_OK; + + memset(blob, 0, sizeof(unsigned char *) * ED_PAGES); + memset(len, 0, sizeof(size_t) * ED_PAGES); + for (pg = 0; pg < ED_PAGES; pg++) + d[pg] = NULL; + ui_message_font(face, &points, &dpi); + ui_measure_init(&m, face, points, dpi); + for (pg = 0; pg < ED_PAGES && e == SD_OK; pg++) { + d[pg] = (ui_dlg *)sd_calloc(1, sizeof(ui_dlg)); /* about 40 KB: on the heap */ + if (d[pg] == NULL) { + e = SD_E_NO_MEMORY; + break; + } + e = ui_dlg_init(d[pg], sd_l10n_get(titles[pg]), ED_WIDTH, face, points, ui_measure_cb, &m); + if (e != SD_OK) + break; + d[pg]->style = WS_CHILD | DS_CONTROL; + switch (pg) { + case PG_GENERAL: add_page_general(d[pg], ed); break; + case PG_AUTH: add_page_auth(d[pg]); break; + case PG_CONN: add_page_conn(d[pg]); break; + case PG_MOUNT: add_page_mount(d[pg]); break; + default: add_page_security(d[pg]); break; + } + if (d[pg]->cursor + d[pg]->margin > tallest) + tallest = d[pg]->cursor + d[pg]->margin; + } + for (pg = 0; pg < ED_PAGES && e == SD_OK; pg++) { + d[pg]->min_height = tallest; + e = ui_dlg_build(d[pg], &blob[pg], &len[pg]); + } + for (pg = 0; pg < ED_PAGES; pg++) + sd_free(d[pg]); + ui_measure_free(&m); + if (e != SD_OK) { + for (pg = 0; pg < ED_PAGES; pg++) { + sd_free(blob[pg]); + blob[pg] = NULL; + } + } + return e; +} + +/* ---- the sheet ---- */ + +static sd_err load_drives(ed_ctx *ed) +{ + DWORD mask = GetLogicalDrives(); + ed->ndrives = ui_drive_free_list((uint32_t)mask, ed->drives); + return SD_OK; +} + +static void free_blobs(unsigned char *blob[ED_PAGES]) +{ + size_t pg; + for (pg = 0; pg < ED_PAGES; pg++) { + sd_free(blob[pg]); + blob[pg] = NULL; + } +} + +static sd_err init_ctx(ed_ctx *ed, ui_edit_mode mode, const sd_profile *p) +{ + size_t pg; + ed->mode = mode; + ed->work = *p; + for (pg = 0; pg < ED_PAGES; pg++) { + ed->pages[pg].ed = ed; + ed->pages[pg].index = (int)pg; + ed->pages[pg].hwnd = NULL; + } + return load_drives(ed); +} + +int ui_editor_run(HWND owner, ui_edit_mode mode, sd_profile *p) +{ + ed_ctx *ed; + unsigned char *blob[ED_PAGES]; + size_t len[ED_PAGES], pg; + PROPSHEETPAGEW psp[ED_PAGES]; + PROPSHEETHEADERW hdr; + INT_PTR ret; + sd_err e; + + if (p == NULL) + return 0; + ed = (ed_ctx *)sd_calloc(1, sizeof(ed_ctx)); + if (ed == NULL) + return 0; + e = init_ctx(ed, mode, p); + if (e == SD_OK) + e = build_pages(ed, blob, len); + if (e != SD_OK) { + sd_free(ed); + return 0; + } + for (pg = 0; pg < ED_PAGES; pg++) { + static const sd_msg_id titles[ED_PAGES] = { SD_MSG_UI_TAB_GENERAL, SD_MSG_UI_TAB_AUTH, SD_MSG_UI_TAB_CONN, + SD_MSG_UI_TAB_MOUNT, SD_MSG_UI_TAB_SECURITY }; + memset(&psp[pg], 0, sizeof(psp[pg])); + psp[pg].dwSize = sizeof(psp[pg]); + psp[pg].dwFlags = PSP_DLGINDIRECT | PSP_USETITLE | PSP_PREMATURE; + psp[pg].hInstance = g_app.inst; + psp[pg].pResource = (LPCDLGTEMPLATEW)blob[pg]; + psp[pg].pfnDlgProc = page_proc; + psp[pg].pszTitle = sd_l10n_get(titles[pg]); + psp[pg].lParam = (LPARAM)&ed->pages[pg]; + } + memset(&hdr, 0, sizeof(hdr)); + hdr.dwSize = sizeof(hdr); + hdr.dwFlags = PSH_PROPSHEETPAGE | PSH_NOAPPLYNOW | PSH_NOCONTEXTHELP; + hdr.hwndParent = owner; + hdr.hInstance = g_app.inst; + hdr.pszCaption = sd_l10n_get(mode == UI_EDIT_NEW ? SD_MSG_UI_EDITOR_CAPTION_NEW + : mode == UI_EDIT_EDIT ? SD_MSG_UI_EDITOR_CAPTION_EDIT + : SD_MSG_UI_EDITOR_CAPTION_ADHOC); + hdr.nPages = ED_PAGES; + hdr.ppsp = psp; + ret = PropertySheetW(&hdr); + free_blobs(blob); + if (ret > 0) { + /* OK was accepted: every page validated the working copy on its last apply */ + *p = ed->work; + sd_free(ed); + return 1; + } + sd_free(ed); + return 0; +} + +/* ---- confirmation dialog (Yes / No, no default button when default_yes is 0 and no_id is 0) ---- */ + +static INT_PTR CALLBACK confirm_proc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) +{ + (void)lp; + switch (msg) { + case WM_INITDIALOG: + return TRUE; + case WM_COMMAND: + if (LOWORD(wp) == IDOK || LOWORD(wp) == IDCANCEL) { + EndDialog(hwnd, LOWORD(wp) == IDOK ? IDYES : IDNO); + return TRUE; + } + break; + default: + break; + } + return FALSE; +} + +int ui_confirm(HWND owner, sd_msg_id title_id, sd_msg_id text_id, const wchar_t *arg, sd_msg_id yes_id, + sd_msg_id no_id, int default_yes) +{ + wchar_t face[UI_DLG_FACE_MAX]; + WORD points = 8; + int dpi = 96; + ui_measure m; + ui_dlg *d; + unsigned char *blob = NULL; + size_t len = 0; + wchar_t *text = NULL; + sd_arg a[1]; + INT_PTR r = IDNO; + sd_err e; + static const WORD ids[2] = { IDOK, IDCANCEL }; + const wchar_t *texts[2]; + + d = (ui_dlg *)sd_calloc(1, sizeof(ui_dlg)); + if (d == NULL) + return 0; + ui_message_font(face, &points, &dpi); + ui_measure_init(&m, face, points, dpi); + a[0].name = "name"; + a[0].type = SD_ARG_STR; + a[0].v.s = arg ? arg : L""; + text = (arg != NULL) ? sd_l10n_format_alloc(text_id, a, 1) : NULL; + e = ui_dlg_init(d, sd_l10n_get(title_id), 260, face, points, ui_measure_cb, &m); + if (e == SD_OK) + e = ui_dlg_text(d, 0, text ? text : sd_l10n_get(text_id), 0); + if (e == SD_OK) { + texts[0] = sd_l10n_get(yes_id); + texts[1] = sd_l10n_get(no_id); + e = ui_dlg_space(d, 4); + if (e == SD_OK) + e = ui_dlg_buttons(d, ids, texts, 2, default_yes ? IDOK : IDCANCEL); + } + if (e == SD_OK) + e = ui_dlg_build(d, &blob, &len); + ui_measure_free(&m); + sd_free(d); + sd_free(text); + if (e == SD_OK && blob != NULL) { + /* DialogBoxIndirectParamW runs the modal loop; the answer comes back as IDYES or IDNO */ + r = DialogBoxIndirectParamW(g_app.inst, (LPCDLGTEMPLATEW)blob, owner, confirm_proc, 0); + } + sd_free(blob); + return r == IDYES; +} + +/* ---- smoke test (--test-editor) ---- */ + +int ui_editor_smoke_test(void) +{ + static const ui_edit_mode modes[] = { UI_EDIT_NEW, UI_EDIT_EDIT, UI_EDIT_ADHOC }; + HWND parent; + size_t mi, pg; + int ok = 1; + + parent = CreateWindowExW(0, L"STATIC", L"", WS_POPUP, 0, 0, 10, 10, NULL, NULL, g_app.inst, NULL); + if (parent == NULL) + return 0; + for (mi = 0; mi < SD_COUNTOF(modes) && ok; mi++) { + ed_ctx *ed = (ed_ctx *)sd_calloc(1, sizeof(ed_ctx)); + unsigned char *blob[ED_PAGES]; + size_t len[ED_PAGES]; + sd_profile p; + if (ed == NULL) { + ok = 0; + break; + } + sd_profile_defaults(&p); + if (init_ctx(ed, modes[mi], &p) != SD_OK || build_pages(ed, blob, len) != SD_OK) { + sd_free(ed); + ok = 0; + break; + } + for (pg = 0; pg < ED_PAGES; pg++) { + PROPSHEETPAGEW psp; + HWND page; + memset(&psp, 0, sizeof(psp)); + psp.dwSize = sizeof(psp); + psp.lParam = (LPARAM)&ed->pages[pg]; + page = CreateDialogIndirectParamW(g_app.inst, (LPCDLGTEMPLATEW)blob[pg], parent, page_proc, (LPARAM)&psp); + if (page == NULL || !IsWindow(page) || ed->pages[pg].hwnd != page) + ok = 0; + if (page != NULL) + DestroyWindow(page); + } + free_blobs(blob); + sd_free(ed); + } + DestroyWindow(parent); + return ok; +} diff --git a/apps/shelldrive-ui/ui_events.c b/apps/shelldrive-ui/ui_events.c new file mode 100644 index 0000000..bbc193d --- /dev/null +++ b/apps/shelldrive-ui/ui_events.c @@ -0,0 +1,398 @@ +/* shelldrive-ui: the event list (03 §7.7). + * + * Every notification of the tray (connection state, warnings, host key problems, updates) is recorded here by + * ui_events_record whether or not its balloon was shown, so nothing exists only as a transient balloon. The record + * lives for the life of the process (ui_events_model.c keeps the last UI_EV_MAX entries); the window is a view of + * it: a list with the time, the drive or server, the kind and the text, a search box, Copy list (the rows that + * match, tab separated) and Clear. Only the UI thread calls these functions. */ +#include +#include +#include +#include + +#include "sd_base/sd_err.h" +#include "sd_base/sd_mem.h" +#include "sd_l10n/sd_l10n.h" +#include "sd_l10n_ui_ids.h" +#include "ui_dpi.h" +#include "ui_events_model.h" +#include "ui_int.h" +#include "ui_win.h" + +#define EV_ID_LIST 4401 +#define EV_ID_SEARCH 4402 +#define EV_ID_LBL_FIND 4403 +#define EV_ID_COPY 4404 +#define EV_ID_CLEAR 4405 +#define EV_ID_STATUS 4406 +#define EV_CLIENT_W 860 +#define EV_CLIENT_H 460 +#define EV_COLS 4 +#define EV_ALL_KINDS ((1u << UI_EV_KINDS) - 1u) + +static ui_ev_log g_log; +static int g_log_ready; + +static struct { + HWND wnd, list, search, lbl_find, btn_copy, btn_clear, status; + HFONT font; + unsigned dpi; +} g_ev; + +static int dpx(int v) +{ + return ui_dpi_scale(v, g_ev.dpi); +} + +static void log_init_once(void) +{ + if (!g_log_ready) { + ui_ev_init(&g_log); + g_log_ready = 1; + } +} + +static const wchar_t *kind_label(ui_ev_kind k) +{ + static const sd_msg_id ids[UI_EV_KINDS] = { + SD_MSG_UI_EVENTS_KIND_DRIVE, SD_MSG_UI_EVENTS_KIND_HOSTKEY, SD_MSG_UI_EVENTS_KIND_WARNING, + SD_MSG_UI_EVENTS_KIND_ERROR, SD_MSG_UI_EVENTS_KIND_INFO + }; + return sd_l10n_get(ids[(unsigned)k < (unsigned)UI_EV_KINDS ? k : UI_EV_INFO]); +} + +static void search_text(wchar_t *out, size_t cap) +{ + out[0] = 0; + if (g_ev.search != NULL) + GetWindowTextW(g_ev.search, out, (int)cap); +} + +static void refresh_view(void) +{ + wchar_t search[128]; + size_t i, row = 0; + wchar_t *t; + if (g_ev.list == NULL) + return; + search_text(search, SD_COUNTOF(search)); + SendMessageW(g_ev.list, WM_SETREDRAW, FALSE, 0); + SendMessageW(g_ev.list, LVM_DELETEALLITEMS, 0, 0); + /* newest first: the latest event is the one the user looks for */ + for (i = g_log.count; i > 0; i--) { + const ui_ev_entry *e = ui_ev_at(&g_log, i - 1); + wchar_t tm[UI_EV_TIME_MAX]; + LVITEMW it; + int j; + wchar_t *cells[EV_COLS]; + if (!ui_ev_match(e, EV_ALL_KINDS, search)) + continue; + ui_ev_format_time(&e->time, tm); + cells[0] = tm; + cells[1] = (wchar_t *)e->subject; + cells[2] = (wchar_t *)kind_label(e->kind); + cells[3] = (wchar_t *)e->text; + memset(&it, 0, sizeof(it)); + it.mask = LVIF_TEXT; + it.iItem = (int)row; + it.pszText = cells[0]; + SendMessageW(g_ev.list, LVM_INSERTITEMW, 0, (LPARAM)&it); + for (j = 1; j < EV_COLS; j++) { + LVITEMW sub; + memset(&sub, 0, sizeof(sub)); + sub.iSubItem = j; + sub.pszText = cells[j]; + SendMessageW(g_ev.list, LVM_SETITEMTEXTW, (WPARAM)row, (LPARAM)&sub); + } + row++; + } + SendMessageW(g_ev.list, WM_SETREDRAW, TRUE, 0); + InvalidateRect(g_ev.list, NULL, TRUE); + t = ui_format_count(SD_MSG_UI_EVENTS_STATUS_COUNT, (long long)row); + if (t != NULL) { + SetWindowTextW(g_ev.status, t); + sd_free(t); + } + EnableWindow(g_ev.btn_copy, row > 0); + EnableWindow(g_ev.btn_clear, g_log.count > 0); +} + +static void add_entry(ui_ev_kind kind, const ui_ev_time *t, const wchar_t *subject, const wchar_t *text) +{ + log_init_once(); + (void)ui_ev_add(&g_log, kind, t, subject, text); + if (g_ev.wnd != NULL) + refresh_view(); +} + +void ui_events_record(ui_ev_kind kind, const wchar_t *subject, const wchar_t *text) +{ + SYSTEMTIME st; + ui_ev_time t; + GetLocalTime(&st); + t.year = st.wYear; + t.month = st.wMonth; + t.day = st.wDay; + t.hour = st.wHour; + t.minute = st.wMinute; + t.second = st.wSecond; + add_entry(kind, &t, subject, text); +} + +void ui_events_record_at(ui_ev_kind kind, long long unix_time, const wchar_t *subject, const wchar_t *text) +{ + ULARGE_INTEGER u; + FILETIME utc, local; + SYSTEMTIME st; + ui_ev_time t; + if (unix_time <= 0) { + ui_events_record(kind, subject, text); + return; + } + /* seconds since 1970 to 100 ns ticks since 1601, then to the local time of the user */ + u.QuadPart = ((unsigned long long)unix_time + 11644473600ull) * 10000000ull; + utc.dwLowDateTime = u.LowPart; + utc.dwHighDateTime = u.HighPart; + if (!FileTimeToLocalFileTime(&utc, &local) || !FileTimeToSystemTime(&local, &st)) { + ui_events_record(kind, subject, text); + return; + } + t.year = st.wYear; + t.month = st.wMonth; + t.day = st.wDay; + t.hour = st.wHour; + t.minute = st.wMinute; + t.second = st.wSecond; + add_entry(kind, &t, subject, text); +} + +size_t ui_events_count(void) +{ + log_init_once(); + return g_log.count; +} + +static void copy_list(void) +{ + wchar_t search[128]; + wchar_t *text = NULL; + const wchar_t *labels[UI_EV_KINDS]; + size_t lines = 0; + int k; + for (k = 0; k < UI_EV_KINDS; k++) + labels[k] = kind_label((ui_ev_kind)k); + search_text(search, SD_COUNTOF(search)); + if (ui_ev_build_text(&g_log, EV_ALL_KINDS, search, labels, &text, &lines) != SD_OK) + return; + if (lines > 0) + ui_win_copy_text(g_ev.wnd, text, wcslen(text)); + sd_free(text); +} + +static void clear_list(void) +{ + ui_ev_clear(&g_log); + refresh_view(); +} + +static void set_columns(void) +{ + static const int w[EV_COLS] = { 140, 140, 100, 440 }; + int i; + for (i = 0; i < EV_COLS; i++) + SendMessageW(g_ev.list, LVM_SETCOLUMNWIDTH, (WPARAM)i, (LPARAM)dpx(w[i])); +} + +static void layout(HWND hwnd) +{ + RECT rc; + int m, rh, sh, bh, W, H, y, by, lw, x, bw; + if (!g_ev.list || !g_ev.status) + return; + GetClientRect(hwnd, &rc); + W = rc.right - rc.left; + H = rc.bottom - rc.top; + m = dpx(8); + rh = dpx(24); + sh = dpx(20); + bh = dpx(26); + lw = ui_win_text_width(g_ev.font, sd_l10n_get(SD_MSG_UI_LOGS_SEARCH_LABEL)) + dpx(8); + MoveWindow(g_ev.lbl_find, m, m + dpx(4), lw, rh, TRUE); + x = m + lw; + MoveWindow(g_ev.search, x, m, W - x - m > 0 ? W - x - m : 0, rh, TRUE); + y = m + rh + m; + MoveWindow(g_ev.status, m, H - m - sh, W - 2 * m > 0 ? W - 2 * m : 0, sh, TRUE); + by = H - m - sh - m - bh; + bw = ui_win_text_width(g_ev.font, sd_l10n_get(SD_MSG_UI_EVENTS_BTN_COPY)) + dpx(32); + MoveWindow(g_ev.btn_copy, m, by, bw, bh, TRUE); + x = m + bw + m; + bw = ui_win_text_width(g_ev.font, sd_l10n_get(SD_MSG_UI_EVENTS_BTN_CLEAR)) + dpx(32); + MoveWindow(g_ev.btn_clear, x, by, bw, bh, TRUE); + MoveWindow(g_ev.list, m, y, W - 2 * m > 0 ? W - 2 * m : 0, by - m - y > 0 ? by - m - y : 0, TRUE); +} + +static void create_children(HWND hwnd) +{ + HINSTANCE inst = g_app.inst; + static const sd_msg_id cols[EV_COLS] = { + SD_MSG_UI_EVENTS_COL_TIME, SD_MSG_UI_EVENTS_COL_SUBJECT, SD_MSG_UI_EVENTS_COL_KIND, SD_MSG_UI_EVENTS_COL_TEXT + }; + int i; + g_ev.lbl_find = CreateWindowExW(0, L"STATIC", sd_l10n_get(SD_MSG_UI_LOGS_SEARCH_LABEL), + WS_CHILD | WS_VISIBLE | SS_LEFT | SS_NOPREFIX, 0, 0, 10, 10, hwnd, + (HMENU)(UINT_PTR)EV_ID_LBL_FIND, inst, NULL); + g_ev.search = CreateWindowExW(WS_EX_CLIENTEDGE, L"EDIT", L"", + WS_CHILD | WS_VISIBLE | WS_TABSTOP | ES_AUTOHSCROLL, 0, 0, 10, 10, hwnd, + (HMENU)(UINT_PTR)EV_ID_SEARCH, inst, NULL); + g_ev.list = CreateWindowExW(WS_EX_CLIENTEDGE, WC_LISTVIEWW, L"", + WS_CHILD | WS_VISIBLE | WS_TABSTOP | LVS_REPORT | LVS_SINGLESEL | LVS_SHOWSELALWAYS, + 0, 0, 10, 10, hwnd, (HMENU)(UINT_PTR)EV_ID_LIST, inst, NULL); + g_ev.btn_copy = CreateWindowExW(0, L"BUTTON", sd_l10n_get(SD_MSG_UI_EVENTS_BTN_COPY), + WS_CHILD | WS_VISIBLE | WS_TABSTOP | BS_PUSHBUTTON, 0, 0, 10, 10, hwnd, + (HMENU)(UINT_PTR)EV_ID_COPY, inst, NULL); + g_ev.btn_clear = CreateWindowExW(0, L"BUTTON", sd_l10n_get(SD_MSG_UI_EVENTS_BTN_CLEAR), + WS_CHILD | WS_VISIBLE | WS_TABSTOP | BS_PUSHBUTTON, 0, 0, 10, 10, hwnd, + (HMENU)(UINT_PTR)EV_ID_CLEAR, inst, NULL); + g_ev.status = CreateWindowExW(0, L"STATIC", L"", WS_CHILD | WS_VISIBLE | SS_LEFT | SS_NOPREFIX, 0, 0, 10, 10, + hwnd, (HMENU)(UINT_PTR)EV_ID_STATUS, inst, NULL); + SendMessageW(g_ev.list, LVM_SETEXTENDEDLISTVIEWSTYLE, 0, LVS_EX_FULLROWSELECT | LVS_EX_LABELTIP); + for (i = 0; i < EV_COLS; i++) { + LVCOLUMNW col; + memset(&col, 0, sizeof(col)); + col.mask = LVCF_TEXT | LVCF_SUBITEM | LVCF_WIDTH; + col.pszText = (LPWSTR)sd_l10n_get(cols[i]); + col.iSubItem = i; + col.cx = 100; + SendMessageW(g_ev.list, LVM_INSERTCOLUMNW, (WPARAM)i, (LPARAM)&col); + } + ui_win_apply_font(hwnd, g_ev.font); + set_columns(); +} + +static void on_dpi(HWND hwnd, unsigned dpi, const RECT *suggested) +{ + HFONT old = g_ev.font; + if (dpi == 0) + dpi = UI_DPI_BASE; + g_ev.dpi = dpi; + if (suggested) + SetWindowPos(hwnd, NULL, suggested->left, suggested->top, suggested->right - suggested->left, + suggested->bottom - suggested->top, SWP_NOZORDER | SWP_NOACTIVATE); + g_ev.font = ui_win_font(dpi); + ui_win_apply_font(hwnd, g_ev.font); + if (old) + DeleteObject(old); + set_columns(); + layout(hwnd); +} + +static LRESULT CALLBACK ev_proc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) +{ + switch (msg) { + case WM_CREATE: + g_ev.wnd = hwnd; + g_ev.dpi = ui_dpi_window(hwnd); + g_ev.font = ui_win_font(g_ev.dpi); + create_children(hwnd); + layout(hwnd); + refresh_view(); + return 0; + case WM_SIZE: + layout(hwnd); + return 0; + case WM_COMMAND: + if (LOWORD(wp) == EV_ID_COPY && HIWORD(wp) == BN_CLICKED) + copy_list(); + else if (LOWORD(wp) == EV_ID_CLEAR && HIWORD(wp) == BN_CLICKED) + clear_list(); + else if (LOWORD(wp) == EV_ID_SEARCH && HIWORD(wp) == EN_CHANGE) + refresh_view(); + return 0; + case WM_CLOSE: + DestroyWindow(hwnd); + return 0; + case WM_DPICHANGED: + on_dpi(hwnd, (unsigned)LOWORD(wp), (const RECT *)lp); + return 0; + case WM_DESTROY: + if (g_ev.font) + DeleteObject(g_ev.font); + memset(&g_ev, 0, sizeof(g_ev)); + return 0; + default: + break; + } + return DefWindowProcW(hwnd, msg, wp, lp); +} + +static HWND create_window(int visible) +{ + wchar_t cls[320]; + HWND w; + log_init_once(); + ui_win_class_name(L"events", cls); + if (!ui_win_register(g_app.inst, cls, ev_proc)) + return NULL; + if (g_ev.wnd != NULL && IsWindow(g_ev.wnd)) + return g_ev.wnd; + w = CreateWindowExW(0, cls, sd_l10n_get(SD_MSG_UI_EVENTS_TITLE), WS_OVERLAPPEDWINDOW | WS_CLIPCHILDREN, + CW_USEDEFAULT, CW_USEDEFAULT, EV_CLIENT_W, EV_CLIENT_H, NULL, NULL, g_app.inst, NULL); + if (w == NULL) + return NULL; + SetWindowPos(w, NULL, 0, 0, ui_dpi_scale(EV_CLIENT_W, ui_dpi_window(w)), + ui_dpi_scale(EV_CLIENT_H, ui_dpi_window(w)), SWP_NOMOVE | SWP_NOZORDER | SWP_NOACTIVATE); + if (visible) + ShowWindow(w, SW_SHOW); + return w; +} + +void ui_events_open(void) +{ + if (g_ev.wnd != NULL && IsWindow(g_ev.wnd)) { + ShowWindow(g_ev.wnd, IsIconic(g_ev.wnd) ? SW_RESTORE : SW_SHOW); + SetForegroundWindow(g_ev.wnd); + return; + } + (void)create_window(1); +} + +void ui_events_close(void) +{ + if (g_ev.wnd != NULL && IsWindow(g_ev.wnd)) + DestroyWindow(g_ev.wnd); +} + +HWND ui_events_create_hidden(void) +{ + return create_window(0); +} + +/* --test-windows: three events show as three rows, a search narrows them, Clear empties the list. The window must + exist (ui_events_create_hidden). */ +int ui_events_controls_ok(void) +{ + static const wchar_t k_text[] = L"Smoke event"; + int ok; + if (g_ev.list == NULL || g_ev.search == NULL) + return 0; + ui_ev_clear(&g_log); + ui_events_record(UI_EV_DRIVE, L"E:", k_text); + ui_events_record(UI_EV_WARNING, L"example.org", L"Other"); + ui_events_record(UI_EV_ERROR, L"F:", k_text); + ok = ListView_GetItemCount(g_ev.list) == 3 && IsWindowEnabled(g_ev.btn_copy) && IsWindowEnabled(g_ev.btn_clear); + SetWindowTextW(g_ev.search, L"smoke"); + refresh_view(); + ok = ok && ListView_GetItemCount(g_ev.list) == 2; + /* an entry of the agent's history carries the time it happened (2001-09-09 01:46:40 UTC is 1000000000) */ + ui_events_record_at(UI_EV_DRIVE, 1000000000ll, L"G:", k_text); + ok = ok && ListView_GetItemCount(g_ev.list) == 3; + { + const ui_ev_entry *e = ui_ev_at(&g_log, g_log.count - 1); + ok = ok && e != NULL && e->time.year >= 2001 && e->time.year <= 2001 && e->time.month >= 9 && e->time.month <= 9; + } + SetWindowTextW(g_ev.search, L""); + clear_list(); + ok = ok && ListView_GetItemCount(g_ev.list) == 0 && !IsWindowEnabled(g_ev.btn_copy) && + !IsWindowEnabled(g_ev.btn_clear); + return ok; +} diff --git a/apps/shelldrive-ui/ui_events_model.c b/apps/shelldrive-ui/ui_events_model.c new file mode 100644 index 0000000..b361cfa --- /dev/null +++ b/apps/shelldrive-ui/ui_events_model.c @@ -0,0 +1,164 @@ +/* shelldrive-ui: the model of the event list (03 §7.7). See ui_events_model.h. */ +#include +#include + +#include "sd_base/sd_mem.h" +#include "ui_events_model.h" + +void ui_ev_init(ui_ev_log *l) +{ + memset(l, 0, sizeof(*l)); + l->next_seq = 1; +} + +void ui_ev_clear(ui_ev_log *l) +{ + unsigned long long seq = l->next_seq; + memset(l, 0, sizeof(*l)); + l->next_seq = seq != 0 ? seq : 1; +} + +static void copy_cut(wchar_t *dst, size_t cap, const wchar_t *src) +{ + size_t n = src != NULL ? wcslen(src) : 0; + size_t i; + if (n >= cap) + n = cap - 1; + for (i = 0; i < n; i++) + dst[i] = src[i] < 0x20 ? L' ' : src[i]; /* control characters would break the rows and the copy */ + dst[n] = 0; +} + +unsigned long long ui_ev_add(ui_ev_log *l, ui_ev_kind kind, const ui_ev_time *t, const wchar_t *subject, + const wchar_t *text) +{ + ui_ev_entry *e; + size_t idx; + if (l->next_seq == 0) + l->next_seq = 1; + if (l->count == UI_EV_MAX) { + idx = l->first; + l->first = (l->first + 1) % UI_EV_MAX; + } else { + idx = (l->first + l->count) % UI_EV_MAX; + l->count++; + } + e = &l->entry[idx]; + memset(e, 0, sizeof(*e)); + e->seq = l->next_seq++; + if (t != NULL) + e->time = *t; + e->kind = ((unsigned)kind < (unsigned)UI_EV_KINDS) ? kind : UI_EV_INFO; + copy_cut(e->subject, UI_EV_SUBJECT_MAX, subject); + copy_cut(e->text, UI_EV_TEXT_MAX, text); + return e->seq; +} + +const ui_ev_entry *ui_ev_at(const ui_ev_log *l, size_t i) +{ + if (i >= l->count) + return NULL; + return &l->entry[(l->first + i) % UI_EV_MAX]; +} + +static wchar_t *put_digits(wchar_t *p, unsigned v, unsigned width) +{ + unsigned i; + for (i = width; i > 0; i--) { + p[i - 1] = (wchar_t)(L'0' + v % 10); + v /= 10; + } + return p + width; +} + +void ui_ev_format_time(const ui_ev_time *t, wchar_t out[UI_EV_TIME_MAX]) +{ + wchar_t *p = out; + p = put_digits(p, t->year, 4); + *p++ = L'-'; + p = put_digits(p, t->month, 2); + *p++ = L'-'; + p = put_digits(p, t->day, 2); + *p++ = L' '; + p = put_digits(p, t->hour, 2); + *p++ = L':'; + p = put_digits(p, t->minute, 2); + *p++ = L':'; + p = put_digits(p, t->second, 2); + *p = 0; +} + +static wchar_t lower(wchar_t c) +{ + return (c >= L'A' && c <= L'Z') ? (wchar_t)(c + (L'a' - L'A')) : c; +} + +/* Not the CRT wcsstr: the x86 build dispatches to SSE4.2 code that the x86-ia32 ISA audit has not reviewed. */ +int ui_ev_contains(const wchar_t *s, const wchar_t *needle) +{ + size_t i, j; + if (needle == NULL || needle[0] == 0) + return 1; + if (s == NULL) + return 0; + for (i = 0; s[i] != 0; i++) { + for (j = 0; needle[j] != 0 && s[i + j] != 0 && lower(s[i + j]) == lower(needle[j]); j++) + ; + if (needle[j] == 0) + return 1; + } + return 0; +} + +int ui_ev_match(const ui_ev_entry *e, unsigned kind_mask, const wchar_t *search) +{ + if ((kind_mask & (1u << (unsigned)e->kind)) == 0) + return 0; + return ui_ev_contains(e->subject, search) || ui_ev_contains(e->text, search); +} + +sd_err ui_ev_build_text(const ui_ev_log *l, unsigned kind_mask, const wchar_t *search, + const wchar_t *const kind_label[UI_EV_KINDS], wchar_t **out, size_t *lines) +{ + size_t i, need = 1, n = 0; + wchar_t *buf, *p; + *out = NULL; + if (lines != NULL) + *lines = 0; + for (i = 0; i < l->count; i++) { + const ui_ev_entry *e = ui_ev_at(l, i); + if (!ui_ev_match(e, kind_mask, search)) + continue; + need += UI_EV_TIME_MAX + wcslen(e->subject) + wcslen(kind_label[e->kind]) + wcslen(e->text) + 5; + } + buf = (wchar_t *)sd_alloc(need * sizeof(wchar_t)); + if (buf == NULL) + return SD_E_NO_MEMORY; + p = buf; + for (i = 0; i < l->count; i++) { + const ui_ev_entry *e = ui_ev_at(l, i); + wchar_t tm[UI_EV_TIME_MAX]; + const wchar_t *parts[4]; + size_t k; + if (!ui_ev_match(e, kind_mask, search)) + continue; + ui_ev_format_time(&e->time, tm); + parts[0] = tm; + parts[1] = e->subject; + parts[2] = kind_label[e->kind]; + parts[3] = e->text; + for (k = 0; k < 4; k++) { + size_t len = wcslen(parts[k]); + memcpy(p, parts[k], len * sizeof(wchar_t)); + p += len; + *p++ = k < 3 ? L'\t' : L'\r'; + } + *p++ = L'\n'; + n++; + } + *p = 0; + *out = buf; + if (lines != NULL) + *lines = n; + return SD_OK; +} diff --git a/apps/shelldrive-ui/ui_events_model.h b/apps/shelldrive-ui/ui_events_model.h new file mode 100644 index 0000000..25f58a7 --- /dev/null +++ b/apps/shelldrive-ui/ui_events_model.h @@ -0,0 +1,79 @@ +/* shelldrive-ui: the model of the event list (03 §7.7). + * + * "Every notification is also recorded in the main window's event list so nothing exists only as a transient + * balloon." The list keeps the most recent UI_EV_MAX entries in a ring (the oldest is dropped first): when it + * happened, the drive or server it is about, a kind (drive, host key, warning, error, information) and the text + * as it was shown to the user. This module adds entries, filters them by kind and by a search text, and builds + * the text that Copy puts on the clipboard. + * + * Pure: no window or process calls, so the unit tests (tests/ui) build this file on its own. */ +#ifndef SD_UI_EVENTS_MODEL_H +#define SD_UI_EVENTS_MODEL_H + +#include + +#include "sd/base.h" +#include "sd_base/sd_err.h" + +#define UI_EV_MAX 200u /* entries kept; the oldest is dropped first */ +#define UI_EV_SUBJECT_MAX 64 /* UTF-16 units including the terminator */ +#define UI_EV_TEXT_MAX 256 +#define UI_EV_TIME_MAX 24 /* "YYYY-MM-DD HH:MM:SS" plus its NUL */ + +typedef enum ui_ev_kind { + UI_EV_DRIVE = 0, /* a drive changed state (mounted, reconnecting, unmounted) */ + UI_EV_HOSTKEY, /* host key changed, wrong key type, new keys offered */ + UI_EV_WARNING, /* legacy algorithms, Terrapin, other warnings */ + UI_EV_ERROR, /* a mount failed, changes could not be written */ + UI_EV_INFO, /* updates and prompts that timed out */ + UI_EV_KINDS +} ui_ev_kind; + +typedef struct ui_ev_time { + unsigned year, month, day, hour, minute, second; +} ui_ev_time; + +typedef struct ui_ev_entry { + unsigned long long seq; /* 1, 2, ... in the order the entries were added */ + ui_ev_time time; + ui_ev_kind kind; + wchar_t subject[UI_EV_SUBJECT_MAX]; /* drive letter ("E:") or server name; may be empty */ + wchar_t text[UI_EV_TEXT_MAX]; +} ui_ev_entry; + +typedef struct ui_ev_log { + ui_ev_entry entry[UI_EV_MAX]; + size_t first; /* ring index of the oldest entry */ + size_t count; + unsigned long long next_seq; +} ui_ev_log; + +void ui_ev_init(ui_ev_log *l); +/* Forgets every entry; sequence numbers keep growing. */ +void ui_ev_clear(ui_ev_log *l); + +/* Adds an entry; over-long subject and text are cut and control characters become blanks. A kind out of range is + stored as information. Returns the entry's sequence number. */ +unsigned long long ui_ev_add(ui_ev_log *l, ui_ev_kind kind, const ui_ev_time *t, const wchar_t *subject, + const wchar_t *text); + +/* Entry i, 0 = the oldest; NULL out of range. */ +const ui_ev_entry *ui_ev_at(const ui_ev_log *l, size_t i); + +/* "YYYY-MM-DD HH:MM:SS". */ +void ui_ev_format_time(const ui_ev_time *t, wchar_t out[UI_EV_TIME_MAX]); + +/* 1 when the entry passes the filters: kind_mask has bit (1 << kind) for each shown kind, search is matched + case-insensitively against subject and text ("" or NULL matches everything). */ +int ui_ev_match(const ui_ev_entry *e, unsigned kind_mask, const wchar_t *search); + +/* Case-insensitive (ASCII) substring test; an empty needle matches. */ +int ui_ev_contains(const wchar_t *s, const wchar_t *needle); + +/* One line per matching entry, oldest first, joined with CR LF: "timesubjectkindtext". kind_label[k] + is the localized name of kind k. Sets *lines to the number of entries written. sd_alloc'd, NUL-terminated (free + with sd_free). */ +sd_err ui_ev_build_text(const ui_ev_log *l, unsigned kind_mask, const wchar_t *search, + const wchar_t *const kind_label[UI_EV_KINDS], wchar_t **out, size_t *lines); + +#endif /* SD_UI_EVENTS_MODEL_H */ diff --git a/apps/shelldrive-ui/ui_evhist_model.c b/apps/shelldrive-ui/ui_evhist_model.c new file mode 100644 index 0000000..f0608af --- /dev/null +++ b/apps/shelldrive-ui/ui_evhist_model.c @@ -0,0 +1,117 @@ +/* shelldrive-ui: the model of the agent's event history (ui_evhist_model.h). */ +#include + +#include "ui_evhist_model.h" + +static void put(char *dst, size_t cap, const char *src) +{ + size_t i = 0; + if (src != NULL) + for (; src[i] != 0 && i + 1 < cap; i++) + dst[i] = src[i]; + dst[i] = 0; +} + +static int known_event(const char *e) +{ + return e != NULL && (strcmp(e, "mount.state") == 0 || strcmp(e, "warning") == 0 || + strcmp(e, "hostkeys.announced") == 0); +} + +void ui_evh_init(ui_evh_state *s) +{ + memset(s, 0, sizeof(*s)); +} + +void ui_evh_note_live(ui_evh_state *s, const char *event, const char *mount_id, const char *key, long long now) +{ + ui_evh_live *l; + if (s == NULL || !known_event(event)) + return; + l = &s->live[s->next]; + memset(l, 0, sizeof(*l)); + l->used = 1; + l->time = now; + put(l->event, sizeof(l->event), event); + put(l->mount_id, sizeof(l->mount_id), mount_id); + put(l->key, sizeof(l->key), key); + s->next = (s->next + 1) % UI_EVH_LIVE_SLOTS; +} + +int ui_evh_parse(const sd_json *rec, ui_evh_rec *out) +{ + int64_t seq = 0, t = 0; + const char *ev; + if (rec == NULL || out == NULL) + return 0; + memset(out, 0, sizeof(*out)); + ev = sd_json_get_str(rec, "event"); + if (!known_event(ev) || !sd_json_get_int64(rec, "seq", &seq) || seq < 1) + return 0; + (void)sd_json_get_int64(rec, "time", &t); + out->seq = (unsigned long long)seq; + out->time = t; + put(out->event, sizeof(out->event), ev); + put(out->mount_id, sizeof(out->mount_id), sd_json_get_str(rec, "mount_id")); + put(out->state, sizeof(out->state), sd_json_get_str(rec, "state")); + put(out->code, sizeof(out->code), sd_json_get_str(rec, "code")); + return 1; +} + +void ui_evh_begin(ui_evh_state *s, unsigned long long last_seq) +{ + /* sequence numbers count from 1 in each agent process: a last_seq below the one imported is a new agent */ + s->min_seq = (s->imported_seq != 0 && last_seq >= s->imported_seq) ? s->imported_seq + 1 : 1; +} + +int ui_evh_wanted(ui_evh_state *s, const ui_evh_rec *r) +{ + const char *key; + size_t i; + if (r->seq < s->min_seq) + return 0; + key = strcmp(r->event, "mount.state") == 0 ? r->state : (strcmp(r->event, "warning") == 0 ? r->code : ""); + for (i = 0; i < UI_EVH_LIVE_SLOTS; i++) { + ui_evh_live *l = &s->live[i]; + long long d; + if (!l->used || strcmp(l->event, r->event) != 0 || strcmp(l->mount_id, r->mount_id) != 0 || + strcmp(l->key, key) != 0) + continue; + d = l->time - r->time; + if (d < 0) + d = -d; + if (d <= UI_EVH_MATCH_SECONDS) { + l->used = 0; /* said once by the live event */ + return 0; + } + } + return 1; +} + +void ui_evh_end(ui_evh_state *s, unsigned long long last_seq) +{ + s->imported_seq = last_seq; +} + +void ui_evh_track_init(ui_evh_track *t) +{ + memset(t, 0, sizeof(*t)); +} + +void ui_evh_track_step(ui_evh_track *t, const char *mount_id, const char *state, char prev[24]) +{ + size_t i; + prev[0] = 0; + if (mount_id == NULL || mount_id[0] == 0) + return; + for (i = 0; i < UI_EVH_TRACK; i++) { + if (t->id[i][0] != 0 && strcmp(t->id[i], mount_id) == 0) { + put(prev, 24, t->state[i]); + put(t->state[i], sizeof(t->state[i]), state); + return; + } + } + put(t->id[t->next], sizeof(t->id[t->next]), mount_id); + put(t->state[t->next], sizeof(t->state[t->next]), state); + t->next = (t->next + 1) % UI_EVH_TRACK; +} diff --git a/apps/shelldrive-ui/ui_evhist_model.h b/apps/shelldrive-ui/ui_evhist_model.h new file mode 100644 index 0000000..d1f1fd6 --- /dev/null +++ b/apps/shelldrive-ui/ui_evhist_model.h @@ -0,0 +1,78 @@ +/* shelldrive-ui: the agent's event history in the UI's event list (03 §2.4 events.recent, §7.7). + * + * A UI that starts after the agent has been running asks `events.recent` (feature `events.recent`) and shows the + * history in its event list, so it is not empty. The history is read after the UI subscribed to the live events, so the + * two overlap: a record of the history that this UI has already seen as a live event is not recorded twice. A record + * carries no sequence number in the live events, so a live event is remembered by what it says (kind of event, mount, + * state or warning code) and the second it came in; the history record matches when it says the same within a few + * seconds. When the UI reconnects to the same agent it asks again, and only records newer than the last import are + * taken (the sequence numbers count from 1 in each agent process, so a smaller last_seq means a new agent). + * + * Pure: no window or process calls, so the unit tests (tests/ui) build this file on its own. */ +#ifndef SD_UI_EVHIST_MODEL_H +#define SD_UI_EVHIST_MODEL_H + +#include "sd/base.h" +#include "sd_base/sd_json.h" + +#define UI_EVH_LIVE_SLOTS 64 +#define UI_EVH_MATCH_SECONDS 5 + +/* One record of events.recent, as the agent sends it. Text is cut to these sizes. */ +typedef struct ui_evh_rec { + unsigned long long seq; + long long time; /* unix seconds */ + char event[24]; /* mount.state, warning, hostkeys.announced */ + char mount_id[16]; + char state[24]; /* mount.state: the new state */ + char code[40]; /* error code of a failed state, or the code of a warning */ +} ui_evh_rec; + +typedef struct ui_evh_live { + int used; + long long time; + char event[24]; + char mount_id[16]; + char key[40]; /* the state of a mount.state, the code of a warning, "" otherwise */ +} ui_evh_live; + +typedef struct ui_evh_state { + unsigned long long imported_seq; /* last_seq of the last import; 0 before the first one */ + unsigned long long min_seq; /* records below this were imported already (set by ui_evh_begin) */ + ui_evh_live live[UI_EVH_LIVE_SLOTS]; + size_t next; /* the slot the next live event replaces (oldest first) */ +} ui_evh_state; + +void ui_evh_init(ui_evh_state *s); + +/* A live event this UI has recorded. key: the new state of a mount.state, the code of a warning, NULL otherwise. */ +void ui_evh_note_live(ui_evh_state *s, const char *event, const char *mount_id, const char *key, long long now); + +/* Reads one element of the `events` array. 0 when it is not an object or has no event name or a sequence number. The + event name must be one this UI knows how to show (mount.state, warning, hostkeys.announced); others return 0. */ +int ui_evh_parse(const sd_json *rec, ui_evh_rec *out); + +/* Starts an import: last_seq is the reply's last_seq. Fixes the lowest sequence number that is still to be recorded + (everything when the agent is a new one). */ +void ui_evh_begin(ui_evh_state *s, unsigned long long last_seq); + +/* 1 when the record is to be recorded: it is newer than the last import and no live event said the same (a matching live + event is used up). */ +int ui_evh_wanted(ui_evh_state *s, const ui_evh_rec *r); + +/* Ends the import. */ +void ui_evh_end(ui_evh_state *s, unsigned long long last_seq); + +/* The state of each mount while the history is replayed, to tell a reconnect from a first mount (ui_nt_of_mount_state). */ +#define UI_EVH_TRACK 64 +typedef struct ui_evh_track { + char id[UI_EVH_TRACK][16]; + char state[UI_EVH_TRACK][24]; + size_t next; +} ui_evh_track; + +void ui_evh_track_init(ui_evh_track *t); +/* The state the mount had before the record ("" when unknown). Remembers the record's state for the next one. */ +void ui_evh_track_step(ui_evh_track *t, const char *mount_id, const char *state, char prev[24]); + +#endif /* SD_UI_EVHIST_MODEL_H */ diff --git a/apps/shelldrive-ui/ui_hkissue.c b/apps/shelldrive-ui/ui_hkissue.c new file mode 100644 index 0000000..aadc628 --- /dev/null +++ b/apps/shelldrive-ui/ui_hkissue.c @@ -0,0 +1,491 @@ +/* shelldrive-ui: a changed host key and a wrong host key type (03 §5.2, §5.4, TM-R04). + * + * A failed mount whose mount.state error is host_key_mismatch raises the balloon "The identity of {host} has changed. + * ShellDrive stopped the connection." (7.7: always shown, cannot be disabled). A click opens the details dialog: the + * stored and the offered key (type and SHA-256 fingerprint), the store and line where the old key lives, and what the + * user can do. There is no "connect anyway": to accept the new key the user removes the old one in the host key manager + * (which asks for a confirmation that warns about interception) and connects again, which is a first contact (5.4). + * When the old key lives in a read-only live source (OpenSSH or PuTTY) ShellDrive cannot remove it; the dialog names + * the file and offers "Open file location" and "Stop using keys from this source" (turns the source off in settings.ini, + * because a mismatch in any source blocks, D7). + * + * host_key_type_unavailable raises its own balloon; a click opens the details dialog of 5.2: the key types stored for the + * name next to the host key algorithms the server offered (and the key it presented, with its SHA-256 fingerprint), so a + * key type change is always visible. Buttons: "Manage host keys..." and "Add key from server..." (opens the manager and + * the review of the server's keys, verified inside a connection that uses the stored key). The agent sends no fingerprint + * of the stored keys (the known_hosts type list has none), so the stored side shows types only. */ +#include +#include +#include +#include + +#include "sd_base/sd_mem.h" +#include "sd_config/sd_config.h" +#include "sd_l10n/sd_l10n.h" +#include "sd_l10n_ui_ids.h" +#include "sd_platform/sd_log.h" +#include "ui_dlg.h" +#include "ui_hkissue_model.h" +#include "ui_int.h" +#include "ui_measure.h" +#include "ui_win.h" + +#define HKI_ID_KEYS 4501 +#define HKI_ID_MANAGE 4502 +#define HKI_ID_OPEN 4503 +#define HKI_ID_STOP 4504 +#define HKI_ID_ADD 4505 +#define HKI_WIDTH 300 + +static ui_hki g_last; /* the issue the last balloon was about */ + +static void host_wide(const ui_hki *h, wchar_t *out, size_t cch) +{ + size_t i; + for (i = 0; i + 1 < cch && h->host[i] != 0; i++) + out[i] = (wchar_t)(unsigned char)h->host[i]; + out[i] = 0; +} + +static int user_store_path(wchar_t *out, size_t cap) +{ + wchar_t root[MAX_PATH]; + if (sd_config_root(root, SD_COUNTOF(root)) != SD_OK || wcslen(root) + 13 >= cap) + return 0; + wcscpy_s(out, cap, root); + wcscat_s(out, cap, L"\\known_hosts"); + return 1; +} + +static ui_hki_source source_of(const ui_hki *h) +{ + wchar_t user[MAX_PATH]; + return ui_hki_source_of(h, user_store_path(user, SD_COUNTOF(user)) ? user : NULL); +} + +static void widen_ascii(const char *s, wchar_t *out, size_t cch) +{ + size_t i; + for (i = 0; i + 1 < cch && s[i] != 0; i++) + out[i] = (wchar_t)(unsigned char)s[i]; + out[i] = 0; +} + +/* ---- the balloons ---- */ + +void ui_hkissue_on_mount_state(const sd_json *data, const char *host, const wchar_t *drive) +{ + ui_hki h; + wchar_t whost[UI_HKI_HOST_MAX]; + (void)drive; + if (ui_hki_parse(data, host, &h) == UI_HKI_NONE) + return; + g_last = h; + host_wide(&h, whost, SD_COUNTOF(whost)); + if (h.kind == UI_HKI_CHANGED) { + ui_tray_notify_msg(SD_MSG_UI_HKI_NOTIFY_CHANGED, "host", whost); + } else if (ui_hki_has_stored_type(&h)) { + sd_arg a[2]; + wchar_t stored[UI_HKI_STORED_MAX * (UI_HKI_TYPE_MAX + 2)], *text; + char list[UI_HKI_STORED_MAX * (UI_HKI_TYPE_MAX + 2)]; + if (ui_hki_stored_list(&h, list, sizeof(list)) == 0) + strcpy_s(list, sizeof(list), h.stored_type); + widen_ascii(list, stored, SD_COUNTOF(stored)); + a[0].name = "host"; + a[0].type = SD_ARG_STR; + a[0].v.s = whost; + a[1].name = "stored"; + a[1].type = SD_ARG_STR; + a[1].v.s = stored; + text = sd_l10n_format_alloc(SD_MSG_UI_HKI_NOTIFY_TYPE_STORED, a, 2); + if (text != NULL) { + ui_tray_notify_text(SD_MSG_UI_HKI_NOTIFY_TYPE_STORED, whost, text); + sd_free(text); + } + } else { + ui_tray_notify_msg(SD_MSG_UI_HKI_NOTIFY_TYPE, "host", whost); + } +} + +/* ---- the details dialog ---- */ + +typedef struct hki_dlg { + ui_hki issue; + ui_hki_source src; +} hki_dlg; + +static wchar_t *fmt_type_fp(sd_msg_id id, const char *type, const char *fp) +{ + sd_arg a[2]; + wchar_t wt[UI_HKI_TYPE_MAX], wf[UI_HKI_FP_MAX]; + size_t i; + for (i = 0; i + 1 < SD_COUNTOF(wt) && type[i] != 0; i++) + wt[i] = (wchar_t)(unsigned char)type[i]; + wt[i] = 0; + for (i = 0; i + 1 < SD_COUNTOF(wf) && fp[i] != 0; i++) + wf[i] = (wchar_t)(unsigned char)fp[i]; + wf[i] = 0; + a[0].name = "type"; + a[0].type = SD_ARG_STR; + a[0].v.s = wt; + a[1].name = "fp"; + a[1].type = SD_ARG_STR; + a[1].v.s = wf; + return sd_l10n_format_alloc(id, a, 2); +} + +/* "Stored key: ..." and "Offered now: ..." as the two lines of the fingerprint edit control. */ +static void key_lines(const ui_hki *h, wchar_t *out, size_t cap) +{ + wchar_t *a = fmt_type_fp(SD_MSG_UI_HKI_LINE_STORED, h->stored_type, h->stored_fp); + wchar_t *b = fmt_type_fp(SD_MSG_UI_HKI_LINE_OFFERED, h->offered_type, h->offered_fp); + out[0] = 0; + if (a != NULL) + wcscat_s(out, cap, a); + wcscat_s(out, cap, L"\r\n"); + if (b != NULL) + wcscat_s(out, cap, b); + sd_free(a); + sd_free(b); +} + +static wchar_t *where_text(const ui_hki *h, ui_hki_source src) +{ + sd_arg a[2]; + switch (ui_hki_where_of(src)) { + case UI_HKI_WHERE_USER: + case UI_HKI_WHERE_MACHINE: + a[0].name = "line"; + a[0].type = SD_ARG_INT; + a[0].v.i = (long long)h->line; + return sd_l10n_format_alloc(ui_hki_where_of(src) == UI_HKI_WHERE_USER ? SD_MSG_UI_HKI_WHERE_USER + : SD_MSG_UI_HKI_WHERE_MACHINE, a, 1); + case UI_HKI_WHERE_PUTTY: + return sd_l10n_format_alloc(SD_MSG_UI_HKI_WHERE_PUTTY, NULL, 0); + default: + a[0].name = "path"; + a[0].type = SD_ARG_STR; + a[0].v.s = h->path; + a[1].name = "line"; + a[1].type = SD_ARG_INT; + a[1].v.i = (long long)h->line; + return sd_l10n_format_alloc(SD_MSG_UI_HKI_WHERE_FILE, a, 2); + } +} + +static sd_msg_id todo_msg(ui_hki_source src) +{ + switch (ui_hki_todo_of(src)) { + case UI_HKI_TODO_USER: return SD_MSG_UI_HKI_TODO_USER; + case UI_HKI_TODO_MACHINE: return SD_MSG_UI_HKI_TODO_MACHINE; + default: return SD_MSG_UI_HKI_TODO_SOURCE; + } +} + +/* Builds the template of the details dialog for an issue. Release *blob with sd_free. */ +static sd_err build_dialog(const ui_hki *h, ui_hki_source src, unsigned char **blob, size_t *len) +{ + WORD ids[4]; + const wchar_t *texts[4]; + size_t nb = 0; + wchar_t face[UI_DLG_FACE_MAX]; + WORD points = 8; + int dpi = 96; + ui_measure m; + wchar_t whost[UI_HKI_HOST_MAX]; + wchar_t lines[2 * (UI_HKI_TYPE_MAX + UI_HKI_FP_MAX + 64) + 8]; + wchar_t *intro, *where; + sd_arg a[1]; + ui_dlg *d = (ui_dlg *)sd_calloc(1, sizeof(ui_dlg)); + sd_err e; + if (d == NULL) + return SD_E_NO_MEMORY; + host_wide(h, whost, SD_COUNTOF(whost)); + a[0].name = "host"; + a[0].type = SD_ARG_STR; + a[0].v.s = whost; + intro = sd_l10n_format_alloc(SD_MSG_UI_HKI_INTRO, a, 1); + where = where_text(h, src); + key_lines(h, lines, SD_COUNTOF(lines)); + ui_message_font(face, &points, &dpi); + ui_measure_init(&m, face, points, dpi); + ids[nb] = HKI_ID_MANAGE; texts[nb++] = sd_l10n_get(SD_MSG_UI_HKI_BTN_MANAGE); + if (ui_hki_source_can_open(src, h->path)) { + ids[nb] = HKI_ID_OPEN; texts[nb++] = sd_l10n_get(SD_MSG_UI_HKI_BTN_OPEN); + } + if (ui_hki_source_can_stop(src)) { + ids[nb] = HKI_ID_STOP; texts[nb++] = sd_l10n_get(SD_MSG_UI_HKI_BTN_STOP); + } + ids[nb] = IDCANCEL; texts[nb++] = sd_l10n_get(SD_MSG_UI_HKI_BTN_CLOSE); + e = ui_dlg_init(d, sd_l10n_get(SD_MSG_UI_HKI_TITLE), HKI_WIDTH, face, points, ui_measure_cb, &m); + if (e == SD_OK) e = ui_dlg_text(d, 0, intro != NULL ? intro : L"", SS_NOPREFIX); + if (e == SD_OK) e = ui_dlg_text(d, 0, sd_l10n_get(SD_MSG_UI_HKI_KEYS_LABEL), 0); + if (e == SD_OK) e = ui_dlg_multiline(d, HKI_ID_KEYS, lines, 2); + if (e == SD_OK) e = ui_dlg_space(d, 2); + if (e == SD_OK) e = ui_dlg_text(d, 0, where != NULL ? where : L"", SS_NOPREFIX); + if (e == SD_OK) e = ui_dlg_text(d, 0, sd_l10n_get(todo_msg(src)), SS_NOPREFIX); + if (e == SD_OK) e = ui_dlg_space(d, 4); + /* the safe button, Close, is the default one: Enter never leads to a changed trust decision */ + if (e == SD_OK) e = ui_dlg_buttons(d, ids, texts, nb, IDCANCEL); + if (e == SD_OK) e = ui_dlg_build(d, blob, len); + ui_measure_free(&m); + sd_free(d); + sd_free(intro); + sd_free(where); + return e; +} + +/* "Stored key types: ..." and "Offered by the server: ..." and, when the server presented a key, "Offered now: ...". */ +static void type_lines(const ui_hki *h, wchar_t *out, size_t cap) +{ + char list[UI_HKI_OFFERED_MAX * (UI_HKI_TYPE_MAX + 2)]; + wchar_t wl[UI_HKI_OFFERED_MAX * (UI_HKI_TYPE_MAX + 2)]; + wchar_t *line; + sd_arg a[1]; + out[0] = 0; + if (ui_hki_stored_list(h, list, sizeof(list)) == 0) + strcpy_s(list, sizeof(list), h->stored_type); + widen_ascii(list, wl, SD_COUNTOF(wl)); + a[0].name = "types"; + a[0].type = SD_ARG_STR; + a[0].v.s = wl[0] != 0 ? wl : sd_l10n_get(SD_MSG_UI_HKI_TYPES_NONE); + line = sd_l10n_format_alloc(SD_MSG_UI_HKI_LINE_STORED_TYPES, a, 1); + if (line != NULL) { + wcscat_s(out, cap, line); + sd_free(line); + } + wcscat_s(out, cap, L"\r\n"); + if (ui_hki_offered_list(h, list, sizeof(list)) == 0) + list[0] = 0; + widen_ascii(list, wl, SD_COUNTOF(wl)); + a[0].v.s = wl[0] != 0 ? wl : sd_l10n_get(SD_MSG_UI_HKI_TYPES_NONE); + line = sd_l10n_format_alloc(SD_MSG_UI_HKI_LINE_OFFERED_TYPES, a, 1); + if (line != NULL) { + wcscat_s(out, cap, line); + sd_free(line); + } + if (ui_hki_has_offered_key(h)) { + line = fmt_type_fp(SD_MSG_UI_HKI_LINE_OFFERED, h->offered_type, h->offered_fp); + wcscat_s(out, cap, L"\r\n"); + if (line != NULL) { + wcscat_s(out, cap, line); + sd_free(line); + } + } +} + +/* Builds the template of the key type dialog (5.2). Release *blob with sd_free. */ +static sd_err build_type_dialog(const ui_hki *h, unsigned char **blob, size_t *len) +{ + static const WORD ids[3] = { HKI_ID_MANAGE, HKI_ID_ADD, IDCANCEL }; + const wchar_t *texts[3]; + wchar_t face[UI_DLG_FACE_MAX]; + WORD points = 8; + int dpi = 96; + ui_measure m; + wchar_t whost[UI_HKI_HOST_MAX]; + wchar_t lines[(UI_HKI_STORED_MAX + UI_HKI_OFFERED_MAX) * (UI_HKI_TYPE_MAX + 2) + 2 * (UI_HKI_TYPE_MAX + UI_HKI_FP_MAX + 64)]; + wchar_t *intro; + sd_arg a[1]; + ui_dlg *d = (ui_dlg *)sd_calloc(1, sizeof(ui_dlg)); + sd_err e; + if (d == NULL) + return SD_E_NO_MEMORY; + host_wide(h, whost, SD_COUNTOF(whost)); + a[0].name = "host"; + a[0].type = SD_ARG_STR; + a[0].v.s = whost; + intro = sd_l10n_format_alloc(SD_MSG_UI_HKI_TYPE_INTRO, a, 1); + type_lines(h, lines, SD_COUNTOF(lines)); + ui_message_font(face, &points, &dpi); + ui_measure_init(&m, face, points, dpi); + texts[0] = sd_l10n_get(SD_MSG_UI_HKI_BTN_MANAGE); + texts[1] = sd_l10n_get(SD_MSG_UI_HKI_BTN_ADD); + texts[2] = sd_l10n_get(SD_MSG_UI_HKI_BTN_CLOSE); + e = ui_dlg_init(d, sd_l10n_get(SD_MSG_UI_HKI_TYPE_TITLE), HKI_WIDTH, face, points, ui_measure_cb, &m); + if (e == SD_OK) e = ui_dlg_text(d, 0, intro != NULL ? intro : L"", SS_NOPREFIX); + if (e == SD_OK) e = ui_dlg_text(d, 0, sd_l10n_get(SD_MSG_UI_HKI_KEYS_LABEL), 0); + if (e == SD_OK) e = ui_dlg_multiline(d, HKI_ID_KEYS, lines, 6); + if (e == SD_OK) e = ui_dlg_space(d, 2); + if (e == SD_OK) e = ui_dlg_text(d, 0, sd_l10n_get(SD_MSG_UI_HKI_TYPE_TODO), SS_NOPREFIX); + if (e == SD_OK) e = ui_dlg_space(d, 4); + /* Close is the default button: Enter never adds a key */ + if (e == SD_OK) e = ui_dlg_buttons(d, ids, texts, 3, IDCANCEL); + if (e == SD_OK) e = ui_dlg_build(d, blob, len); + ui_measure_free(&m); + sd_free(d); + sd_free(intro); + return e; +} + +/* "Stop using keys from this source": asks, then turns the source off in settings.ini and tells the agent. */ +static void stop_source(HWND owner, ui_hki_source src) +{ + sd_settings s; + wchar_t *text; + const wchar_t *name = src == UI_HKI_SRC_PUTTY ? L"PuTTY" : L"OpenSSH"; + sd_err e; + text = ui_format1(SD_MSG_UI_HKI_STOP_CONFIRM, "source", name); + if (text == NULL) + return; + if (MessageBoxW(owner, text, L"ShellDrive", MB_YESNO | MB_ICONQUESTION | MB_DEFBUTTON2) != IDYES) { + sd_free(text); + return; + } + sd_free(text); + sd_settings_defaults(&s); + (void)sd_settings_load(&s); + if (src == UI_HKI_SRC_PUTTY) + s.putty_source = 0; + else + s.openssh_source = 0; + e = sd_settings_save(&s); + if (e != SD_OK) { + ui_win_error(owner, SD_MSG_UI_SETTINGS_SAVE_FAILED, e); + return; + } + (void)ui_ipc_config_reload(); + text = ui_format1(SD_MSG_UI_HKI_STOP_DONE, "source", name); + if (text != NULL) { + ui_win_message(owner, text, MB_OK | MB_ICONINFORMATION); + sd_free(text); + } +} + +static void open_location(HWND owner, const ui_hki *h) +{ + wchar_t args[UI_HKI_PATH_MAX + 16]; + if (ui_hki_select_args(h->path, args, SD_COUNTOF(args))) + ShellExecuteW(owner, L"open", L"explorer.exe", args, NULL, SW_SHOWNORMAL); +} + +static INT_PTR CALLBACK hki_proc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) +{ + hki_dlg *c = (hki_dlg *)GetWindowLongPtrW(hwnd, DWLP_USER); + switch (msg) { + case WM_INITDIALOG: + SetWindowLongPtrW(hwnd, DWLP_USER, (LONG_PTR)lp); + return TRUE; + case WM_COMMAND: + if (c == NULL) + break; + switch (LOWORD(wp)) { + case HKI_ID_MANAGE: + EndDialog(hwnd, HKI_ID_MANAGE); + return TRUE; + case HKI_ID_ADD: + EndDialog(hwnd, HKI_ID_ADD); + return TRUE; + case HKI_ID_OPEN: + open_location(hwnd, &c->issue); + return TRUE; + case HKI_ID_STOP: + stop_source(hwnd, c->src); + return TRUE; + case IDCANCEL: + EndDialog(hwnd, IDCANCEL); + return TRUE; + default: + break; + } + break; + default: + break; + } + return FALSE; +} + +static void show_changed(const ui_hki *h) +{ + unsigned char *blob = NULL; + size_t len = 0; + hki_dlg c; + INT_PTR r = IDCANCEL; + c.issue = *h; + c.src = source_of(h); + if (build_dialog(h, c.src, &blob, &len) == SD_OK && blob != NULL) + r = DialogBoxIndirectParamW(g_app.inst, (LPCDLGTEMPLATEW)blob, g_app.main, hki_proc, (LPARAM)&c); + sd_free(blob); + if (r == HKI_ID_MANAGE) + ui_hostkeys_open_for(h->host); +} + +static void show_type(const ui_hki *h) +{ + unsigned char *blob = NULL; + size_t len = 0; + hki_dlg c; + INT_PTR r = IDCANCEL; + c.issue = *h; + c.src = UI_HKI_SRC_USER; + if (build_type_dialog(h, &blob, &len) == SD_OK && blob != NULL) + r = DialogBoxIndirectParamW(g_app.inst, (LPCDLGTEMPLATEW)blob, g_app.main, hki_proc, (LPARAM)&c); + sd_free(blob); + if (r == HKI_ID_MANAGE) + ui_hostkeys_open_for(h->host); + else if (r == HKI_ID_ADD) + ui_hostkeys_add_for(h->host, ui_hki_add_target(h)); +} + +void ui_hkissue_open_last(void) +{ + if (g_last.kind == UI_HKI_CHANGED) + show_changed(&g_last); + else if (g_last.kind == UI_HKI_TYPE_UNAVAILABLE) + show_type(&g_last); +} + +/* --test-windows: the template builds and parses for every source, with and without the optional buttons. */ +int ui_hkissue_smoke(void) +{ + static const ui_hki_source k_src[] = { UI_HKI_SRC_USER, UI_HKI_SRC_MACHINE, UI_HKI_SRC_OPENSSH, UI_HKI_SRC_PUTTY, + UI_HKI_SRC_FILE }; + ui_hki h; + size_t i, count = 0, offs[UI_DLG_MAX_ITEMS]; + int ok = 1; + memset(&h, 0, sizeof(h)); + h.kind = UI_HKI_CHANGED; + strcpy_s(h.host, sizeof(h.host), "example.org"); + strcpy_s(h.stored_type, sizeof(h.stored_type), "ssh-ed25519"); + strcpy_s(h.stored_fp, sizeof(h.stored_fp), "SHA256:abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG"); + strcpy_s(h.offered_type, sizeof(h.offered_type), "ssh-ed25519"); + strcpy_s(h.offered_fp, sizeof(h.offered_fp), "SHA256:ZYXWVUTSRQPONMLKJIHGFEDCBA9876543210zyxwvut"); + wcscpy_s(h.path, SD_COUNTOF(h.path), L"C:\\Users\\test\\.ssh\\known_hosts"); + h.line = 7; + for (i = 0; i < SD_COUNTOF(k_src) && ok; i++) { + unsigned char *blob = NULL; + size_t len = 0; + /* intro, label, edit, where, todo, then the buttons: Manage and Close always, Open and Stop by source */ + size_t want = 5 + 2 + (ui_hki_source_can_open(k_src[i], h.path) ? 1u : 0u) + + (ui_hki_source_can_stop(k_src[i]) ? 1u : 0u); + ok = build_dialog(&h, k_src[i], &blob, &len) == SD_OK && blob != NULL && + ui_dlg_parse_check(blob, len, &count, offs, SD_COUNTOF(offs)) == SD_OK && count == want; + sd_free(blob); + } + if (ok) { + /* the key type dialog (5.2): without and with the key the server presented */ + unsigned char *blob = NULL; + size_t len = 0, j; + ui_hki t; + memset(&t, 0, sizeof(t)); + t.kind = UI_HKI_TYPE_UNAVAILABLE; + strcpy_s(t.host, sizeof(t.host), "example.org"); + strcpy_s(t.stored_types[0], UI_HKI_TYPE_MAX, "ssh-ed25519"); + strcpy_s(t.stored_types[1], UI_HKI_TYPE_MAX, "ssh-rsa"); + t.n_stored = 2; + strcpy_s(t.offered_types[0], UI_HKI_TYPE_MAX, "ecdsa-sha2-nistp256"); + t.n_offered = 1; + for (j = 0; j < 2 && ok; j++) { + if (j == 1) { + strcpy_s(t.offered_type, sizeof(t.offered_type), "ecdsa-sha2-nistp256"); + strcpy_s(t.offered_fp, sizeof(t.offered_fp), "SHA256:ZYXWVUTSRQPONMLKJIHGFEDCBA9876543210zyxwvut"); + } + /* intro, label, edit, todo, then Manage, Add and Close */ + ok = build_type_dialog(&t, &blob, &len) == SD_OK && blob != NULL && + ui_dlg_parse_check(blob, len, &count, offs, SD_COUNTOF(offs)) == SD_OK && count == 7; + sd_free(blob); + blob = NULL; + } + } + return ok; +} diff --git a/apps/shelldrive-ui/ui_hkissue_model.c b/apps/shelldrive-ui/ui_hkissue_model.c new file mode 100644 index 0000000..42c0f78 --- /dev/null +++ b/apps/shelldrive-ui/ui_hkissue_model.c @@ -0,0 +1,305 @@ +/* shelldrive-ui: the model of the changed-key and wrong-key-type notifications (ui_hkissue_model.h). */ +#include +#include +#include + +#include "ui_hkissue_model.h" + +/* Copies a JSON string reduced to printable ASCII. */ +static void ascii_copy(const sd_json *obj, const char *key, char *out, size_t cap) +{ + const char *s = sd_json_get_str(obj, key); + size_t i; + out[0] = 0; + if (s == NULL) + return; + for (i = 0; i + 1 < cap && s[i] != 0; i++) { + unsigned char c = (unsigned char)s[i]; + out[i] = (c >= 0x20 && c < 0x7f) ? (char)c : '?'; + } + out[i] = 0; +} + +static void ascii_cut(const char *s, char *out, size_t cap) +{ + size_t i; + for (i = 0; i + 1 < cap && s[i] != 0; i++) { + unsigned char c = (unsigned char)s[i]; + out[i] = (c >= 0x20 && c < 0x7f) ? (char)c : '?'; + } + out[i] = 0; +} + +/* Reads a JSON array of strings into names; returns the number kept (empty strings are skipped). */ +static size_t read_names(const sd_json *obj, const char *key, char (*names)[UI_HKI_TYPE_MAX], size_t max) +{ + const sd_json *arr = sd_json_get(obj, key); + size_t i, n = sd_json_count(arr), kept = 0; + if (sd_json_type_of(arr) != SD_JSON_ARRAY) + return 0; + for (i = 0; i < n && kept < max; i++) { + const char *s = sd_json_str(sd_json_at(arr, i), NULL); + if (s == NULL || s[0] == 0) + continue; + ascii_cut(s, names[kept], UI_HKI_TYPE_MAX); + kept++; + } + return kept; +} + +/* Splits a comma-joined list ("ssh-rsa,ssh-ed25519") into names. */ +static size_t split_names(const char *csv, char (*names)[UI_HKI_TYPE_MAX], size_t max) +{ + size_t kept = 0; + const char *p = csv; + while (p != NULL && *p != 0 && kept < max) { + const char *e = strchr(p, ','); + size_t len = e != NULL ? (size_t)(e - p) : strlen(p); + while (len > 0 && *p == ' ') { + p++; + len--; + } + if (len > 0) { + size_t k; + for (k = 0; k + 1 < UI_HKI_TYPE_MAX && k < len; k++) { + unsigned char c = (unsigned char)p[k]; + names[kept][k] = (c >= 0x20 && c < 0x7f) ? (char)c : '?'; + } + names[kept][k] = 0; + kept++; + } + p = e != NULL ? e + 1 : NULL; + } + return kept; +} + +static ui_hki_store store_of_token(const char *s) +{ + if (s == NULL) + return UI_HKI_STORE_UNKNOWN; + if (strcmp(s, "machine") == 0) + return UI_HKI_STORE_MACHINE; + if (strcmp(s, "user") == 0) + return UI_HKI_STORE_USER; + if (strcmp(s, "openssh") == 0) + return UI_HKI_STORE_OPENSSH; + if (strcmp(s, "putty") == 0) + return UI_HKI_STORE_PUTTY; + return UI_HKI_STORE_UNKNOWN; +} + +static int ends_with_i(const wchar_t *s, const wchar_t *suffix) +{ + size_t n = wcslen(s), m = wcslen(suffix); + return n >= m && _wcsicmp(s + n - m, suffix) == 0; +} + +ui_hki_kind ui_hki_parse(const sd_json *data, const char *fallback_host, ui_hki *out) +{ + const sd_json *err = sd_json_get(data, "error"); + const char *code = sd_json_get_str(err, "code"); + const char *path; + int64_t line = 0; + size_t i, o = 0; + + memset(out, 0, sizeof(*out)); + if (err == NULL || code == NULL) + return UI_HKI_NONE; + if (strcmp(code, "host_key_mismatch") == 0) + out->kind = UI_HKI_CHANGED; + else if (strcmp(code, "host_key_type_unavailable") == 0) + out->kind = UI_HKI_TYPE_UNAVAILABLE; + else + return UI_HKI_NONE; + ascii_copy(data, "mount_id", out->mount_id, sizeof(out->mount_id)); + ascii_copy(err, "host", out->host, sizeof(out->host)); + if (out->host[0] == 0 && fallback_host != NULL) { + for (i = 0; i + 1 < sizeof(out->host) && fallback_host[i] != 0; i++) { + unsigned char c = (unsigned char)fallback_host[i]; + out->host[i] = (c >= 0x20 && c < 0x7f) ? (char)c : '?'; + } + out->host[i] = 0; + } + ascii_copy(err, "lookup_name", out->lookup_name, sizeof(out->lookup_name)); + out->store = store_of_token(sd_json_get_str(err, "store_kind")); + if (out->kind == UI_HKI_TYPE_UNAVAILABLE) { + out->n_stored = read_names(err, "stored_types", out->stored_types, UI_HKI_STORED_MAX); + out->n_offered = read_names(err, "offered_types", out->offered_types, UI_HKI_OFFERED_MAX); + } + ascii_copy(err, "stored_type", out->stored_type, sizeof(out->stored_type)); + ascii_copy(err, "stored_fp", out->stored_fp, sizeof(out->stored_fp)); + ascii_copy(err, "offered_type", out->offered_type, sizeof(out->offered_type)); + ascii_copy(err, "offered_fp", out->offered_fp, sizeof(out->offered_fp)); + path = sd_json_get_str(err, "store_path"); + if (path != NULL) { + wchar_t tmp[UI_HKI_PATH_MAX]; + int n = MultiByteToWideChar(CP_UTF8, 0, path, -1, tmp, (int)(sizeof(tmp) / sizeof(tmp[0]))); + if (n > 0) { + for (i = 0; tmp[i] != 0 && o + 1 < UI_HKI_PATH_MAX; i++) + if (tmp[i] >= 0x20 && tmp[i] != 0x7f) + out->path[o++] = tmp[i]; + } + out->path[o] = 0; + } + if (out->kind == UI_HKI_TYPE_UNAVAILABLE && out->n_stored == 0) + out->n_stored = split_names(out->stored_type, out->stored_types, UI_HKI_STORED_MAX); /* an older agent */ + if (sd_json_get_int64(err, "store_line", &line) && line > 0 && line < 100000000) + out->line = (unsigned)line; + return out->kind; +} + +/* 1 when needle occurs in s. Not the CRT wcsstr: its x86 version dispatches to SSE4.2 code that the x86-ia32 ISA + audit has not reviewed (tools/sd-isacheck/crt-allow.txt), and these strings are short. */ +static int has_substr(const wchar_t *s, const wchar_t *needle) +{ + size_t i, j; + for (i = 0; s[i] != 0; i++) { + for (j = 0; needle[j] != 0 && s[i + j] == needle[j]; j++) + ; + if (needle[j] == 0) + return 1; + } + return 0; +} + +ui_hki_source ui_hki_classify(const wchar_t *path, const wchar_t *user_store) +{ + if (path == NULL || path[0] == 0) + return UI_HKI_SRC_PUTTY; + if (has_substr(path, L"SimonTatham") && has_substr(path, L"PuTTY") && wcschr(path, L':') == NULL) + return UI_HKI_SRC_PUTTY; + if (user_store != NULL && _wcsicmp(path, user_store) == 0) + return UI_HKI_SRC_USER; + if (ends_with_i(path, L"\\LibreStatic\\ShellDrive\\known_hosts")) + return UI_HKI_SRC_MACHINE; + if (ends_with_i(path, L"\\known_hosts") || ends_with_i(path, L"\\known_hosts2") || + ends_with_i(path, L"\\ssh_known_hosts")) + return UI_HKI_SRC_OPENSSH; + return UI_HKI_SRC_FILE; +} + +int ui_hki_source_removable_here(ui_hki_source s) +{ + return s == UI_HKI_SRC_USER; +} + +int ui_hki_source_can_stop(ui_hki_source s) +{ + return s == UI_HKI_SRC_OPENSSH || s == UI_HKI_SRC_PUTTY; +} + +int ui_hki_path_ok(const wchar_t *path) +{ + size_t i, n; + if (path == NULL) + return 0; + n = wcslen(path); + if (n < 4 || n >= UI_HKI_PATH_MAX) + return 0; + if (!(((path[0] >= L'A' && path[0] <= L'Z') || (path[0] >= L'a' && path[0] <= L'z')) && path[1] == L':' && + path[2] == L'\\') && + !(path[0] == L'\\' && path[1] == L'\\')) + return 0; + for (i = 0; i < n; i++) + if (path[i] < 0x20 || path[i] == L'"') + return 0; + return 1; +} + +int ui_hki_source_can_open(ui_hki_source s, const wchar_t *path) +{ + return s != UI_HKI_SRC_PUTTY && s != UI_HKI_SRC_USER && s != UI_HKI_SRC_MACHINE && ui_hki_path_ok(path); +} + +int ui_hki_select_args(const wchar_t *path, wchar_t *out, size_t cap) +{ + static const wchar_t head[] = L"/select,\""; + size_t n; + if (!ui_hki_path_ok(path) || out == NULL) + return 0; + n = wcslen(path); + if (cap < (sizeof(head) / sizeof(head[0])) - 1 + n + 2) + return 0; + wcscpy_s(out, cap, head); + wcscat_s(out, cap, path); + wcscat_s(out, cap, L"\""); + return 1; +} + +ui_hki_where ui_hki_where_of(ui_hki_source s) +{ + switch (s) { + case UI_HKI_SRC_USER: return UI_HKI_WHERE_USER; + case UI_HKI_SRC_MACHINE: return UI_HKI_WHERE_MACHINE; + case UI_HKI_SRC_PUTTY: return UI_HKI_WHERE_PUTTY; + default: return UI_HKI_WHERE_FILE; + } +} + +ui_hki_todo ui_hki_todo_of(ui_hki_source s) +{ + switch (s) { + case UI_HKI_SRC_USER: return UI_HKI_TODO_USER; + case UI_HKI_SRC_MACHINE: return UI_HKI_TODO_MACHINE; + default: return UI_HKI_TODO_SOURCE; + } +} + +int ui_hki_has_stored_type(const ui_hki *h) +{ + return h != NULL && (h->n_stored > 0 || h->stored_type[0] != 0); +} + +ui_hki_source ui_hki_source_of(const ui_hki *h, const wchar_t *user_store) +{ + switch (h->store) { + case UI_HKI_STORE_MACHINE: return UI_HKI_SRC_MACHINE; + case UI_HKI_STORE_USER: return UI_HKI_SRC_USER; + case UI_HKI_STORE_OPENSSH: return UI_HKI_SRC_OPENSSH; + case UI_HKI_STORE_PUTTY: return UI_HKI_SRC_PUTTY; + default: return ui_hki_classify(h->path, user_store); + } +} + +static size_t join_types(const char (*types)[UI_HKI_TYPE_MAX], size_t n, char *out, size_t cap) +{ + size_t i, len = 0, written = 0; + if (out == NULL || cap == 0) + return 0; + out[0] = 0; + for (i = 0; i < n; i++) { + size_t tl = strlen(types[i]); + size_t need = (i > 0 ? 2u : 0u) + tl; + if (len + need + 1 > cap) + break; + if (i > 0) { + out[len++] = ','; + out[len++] = ' '; + } + memcpy(out + len, types[i], tl); + len += tl; + out[len] = 0; + written++; + } + return written; +} + +size_t ui_hki_stored_list(const ui_hki *h, char *out, size_t cap) +{ + return join_types((const char (*)[UI_HKI_TYPE_MAX])h->stored_types, h->n_stored, out, cap); +} + +size_t ui_hki_offered_list(const ui_hki *h, char *out, size_t cap) +{ + return join_types((const char (*)[UI_HKI_TYPE_MAX])h->offered_types, h->n_offered, out, cap); +} + +int ui_hki_has_offered_key(const ui_hki *h) +{ + return h != NULL && h->offered_type[0] != 0 && h->offered_fp[0] != 0; +} + +const char *ui_hki_add_target(const ui_hki *h) +{ + return h->lookup_name[0] != 0 ? h->lookup_name : h->host; +} diff --git a/apps/shelldrive-ui/ui_hkissue_model.h b/apps/shelldrive-ui/ui_hkissue_model.h new file mode 100644 index 0000000..126102a --- /dev/null +++ b/apps/shelldrive-ui/ui_hkissue_model.h @@ -0,0 +1,111 @@ +/* shelldrive-ui: the model of the changed-key and wrong-key-type notifications (03 §5.2, §5.4, TM-R04). + * + * The agent reports a failed mount in the `error` object of a mount.state event (03 §2.4): for host_key_mismatch it + * carries host, lookup_name, stored_type, stored_fp, offered_type, offered_fp, store_path, store_line and store_kind + * (machine, user, openssh, putty); for host_key_type_unavailable it carries host, lookup_name, stored_types[] and + * offered_types[] (the server's host key algorithms) and, when the server presented a key, offered_type and + * offered_fp. This module reads that object, decides where the old key lives (store_kind; an agent that does not send + * it falls back to the file path) and what the user can do about it. There is no "connect anyway" anywhere (5.4): the + * dialog offers the host key manager, the file location of a read-only source and "Stop using keys from this source"; + * the key type dialog (5.2) offers the manager and "Add key from server...". + * + * Pure: no window or process calls, so the unit tests (tests/ui) build this file on its own. */ +#ifndef SD_UI_HKISSUE_MODEL_H +#define SD_UI_HKISSUE_MODEL_H + +#include + +#include "sd/base.h" +#include "sd_base/sd_err.h" +#include "sd_base/sd_json.h" + +#define UI_HKI_HOST_MAX 256 +#define UI_HKI_TYPE_MAX 48 +#define UI_HKI_FP_MAX 64 +#define UI_HKI_PATH_MAX 260 +#define UI_HKI_ID_MAX 40 +#define UI_HKI_STORED_MAX 8 /* stored key types kept */ +#define UI_HKI_OFFERED_MAX 16 /* offered host key algorithms kept */ +#define UI_HKI_KIND_MAX 16 + +typedef enum ui_hki_kind { + UI_HKI_NONE = 0, + UI_HKI_CHANGED, /* host_key_mismatch */ + UI_HKI_TYPE_UNAVAILABLE /* host_key_type_unavailable */ +} ui_hki_kind; + +/* Where the stored (old) key lives. */ +typedef enum ui_hki_source { + UI_HKI_SRC_USER = 0, /* ShellDrive's own list of this user: removable in the host key manager */ + UI_HKI_SRC_MACHINE, /* ShellDrive's list for all users: only an administrator changes it */ + UI_HKI_SRC_OPENSSH, /* a known_hosts file of OpenSSH, read live (5.1) */ + UI_HKI_SRC_PUTTY, /* the PuTTY registry value, read live */ + UI_HKI_SRC_FILE /* some other file */ +} ui_hki_source; + +/* The store_kind token of a mismatch: where the old key lives, as the agent reports it. */ +typedef enum ui_hki_store { UI_HKI_STORE_UNKNOWN = 0, UI_HKI_STORE_MACHINE, UI_HKI_STORE_USER, UI_HKI_STORE_OPENSSH, + UI_HKI_STORE_PUTTY } ui_hki_store; + +typedef struct ui_hki { + ui_hki_kind kind; + char mount_id[UI_HKI_ID_MAX]; + char host[UI_HKI_HOST_MAX]; /* printable ASCII */ + char lookup_name[UI_HKI_HOST_MAX]; /* the name the key was looked up under; "" when not sent */ + ui_hki_store store; /* store_kind of a mismatch; UNKNOWN when the agent did not send it */ + char stored_types[UI_HKI_STORED_MAX][UI_HKI_TYPE_MAX]; /* type-unavailable: the types stored for the name */ + size_t n_stored; + char offered_types[UI_HKI_OFFERED_MAX][UI_HKI_TYPE_MAX]; /* type-unavailable: what the server offers */ + size_t n_offered; + char stored_type[UI_HKI_TYPE_MAX]; + char stored_fp[UI_HKI_FP_MAX]; + char offered_type[UI_HKI_TYPE_MAX]; + char offered_fp[UI_HKI_FP_MAX]; + wchar_t path[UI_HKI_PATH_MAX]; /* file of the old key; "" or a registry subkey for PuTTY */ + unsigned line; /* 1-based line of the old key, 0 when not known */ +} ui_hki; + +/* Reads the data of a mount.state event. Returns the kind (UI_HKI_NONE when the event has no such error). fallback_host + is used when the error names no host (the host of the mount). Text is reduced to printable ASCII ('?' for the + rest); the path loses control characters. */ +ui_hki_kind ui_hki_parse(const sd_json *data, const char *fallback_host, ui_hki *out); + +/* Where the old key of a mismatch lives: store_kind when the agent sent it, otherwise classified from the path + (ui_hki_classify). */ +ui_hki_source ui_hki_source_of(const ui_hki *h, const wchar_t *user_store); + +/* The types as "a, b, c" (printable ASCII, cut at cap - 1). Returns the number of types written. */ +size_t ui_hki_stored_list(const ui_hki *h, char *out, size_t cap); +size_t ui_hki_offered_list(const ui_hki *h, char *out, size_t cap); +/* 1 when the server presented a key (offered_type and offered_fp are known). */ +int ui_hki_has_offered_key(const ui_hki *h); +/* The name to prefill "Add key from server": lookup_name when the agent sent one, otherwise host. */ +const char *ui_hki_add_target(const ui_hki *h); + +/* Classifies the file of the old key. user_store: the path of ShellDrive's own known_hosts of this user (NULL when + unknown). An empty path or a PuTTY registry subkey is PuTTY. */ +ui_hki_source ui_hki_classify(const wchar_t *path, const wchar_t *user_store); + +/* 1 when the user can do something in ShellDrive's own host key manager about this source (remove the key). */ +int ui_hki_source_removable_here(ui_hki_source s); +/* 1 when the source is a live read-only source whose keys can be switched off ("Stop using keys from this source"). */ +int ui_hki_source_can_stop(ui_hki_source s); +/* 1 when "Open file location" applies: the source is a file with a path that is safe to hand to Explorer. */ +int ui_hki_source_can_open(ui_hki_source s, const wchar_t *path); + +/* 1 when path is an absolute drive or UNC path without quotes or control characters (safe in a command line). */ +int ui_hki_path_ok(const wchar_t *path); +/* The Explorer arguments that select the file: /select,"". 0 when the path is not ok or cap is too small. */ +int ui_hki_select_args(const wchar_t *path, wchar_t *out, size_t cap); + +/* Messages the dialog uses for a source: where the key is, and what to do. */ +typedef enum ui_hki_where { UI_HKI_WHERE_USER = 0, UI_HKI_WHERE_MACHINE, UI_HKI_WHERE_FILE, UI_HKI_WHERE_PUTTY } ui_hki_where; +typedef enum ui_hki_todo { UI_HKI_TODO_USER = 0, UI_HKI_TODO_MACHINE, UI_HKI_TODO_SOURCE } ui_hki_todo; +ui_hki_where ui_hki_where_of(ui_hki_source s); +ui_hki_todo ui_hki_todo_of(ui_hki_source s); + +/* 1 when the agent reported a stored key type (the type-unavailable notification then names it). It counts both the + stored_types array and the comma-joined stored_type. */ +int ui_hki_has_stored_type(const ui_hki *h); + +#endif /* SD_UI_HKISSUE_MODEL_H */ diff --git a/apps/shelldrive-ui/ui_hostkeys.c b/apps/shelldrive-ui/ui_hostkeys.c new file mode 100644 index 0000000..05ad3e4 --- /dev/null +++ b/apps/shelldrive-ui/ui_hostkeys.c @@ -0,0 +1,1477 @@ +/* shelldrive-ui: the host key manager (03 §5.1, §5.5, §5.6). + * + * Two tabs. "Known keys" lists the plain keys of the machine store (read-only here) and the user store, read + * with sd_known_hosts (the same library the CLI uses, hk_list/hk_import/hk_remove in apps/shelldrive/cli_cmds2.c); + * this window does no known_hosts file I/O of its own. Remove deletes one key from the user store after a + * confirmation; Import copies the keys of PuTTY or of an OpenSSH known_hosts file into the user store. + * "Announced keys" lists the UpdateHostKeys announcements the agent holds (hostkey.announced.list). Add keys + * applies the proven additions; Add and remove also applies the removals, after a confirmation. Nothing changes + * until the user asks for it (5.6). + * + * "Add key from server..." (5.2, 6.1 hostkey add --from-server) asks for a server name, sends hostkey.add to the + * agent and shows the keys the server offered with their SHA-256 fingerprints. Nothing is stored until the user + * chooses Add; the default button is "Don't add". Add calls hostkey.announced.accept with add_only, as the CLI + * does. The model (parsing, request, reply, error classes) is ui_hostkeys_model.c. */ +#include +#include +#include +#include +#include +#include + +#include "sd_base/sd_err.h" +#include "sd_base/sd_mem.h" +#include "sd_base/sd_json.h" +#include "sd_config/sd_config.h" +#include "sd_l10n/sd_l10n.h" +#include "sd_l10n_ui_ids.h" +#include "sd_ssh/sd_known_hosts.h" +#include "ui_dlg.h" +#include "ui_dpi.h" +#include "ui_hostkeys_model.h" +#include "ui_int.h" +#include "ui_measure.h" +#include "ui_win.h" + +#define HK_ID_TAB 4201 +#define HK_ID_LIST_KNOWN 4202 +#define HK_ID_LIST_ANN 4203 +#define HK_ID_REMOVE 4204 +#define HK_ID_PUTTY 4205 +#define HK_ID_OPENSSH 4206 +#define HK_ID_REFRESH 4207 +#define HK_ID_ADD 4208 +#define HK_ID_ADDALL 4209 +#define HK_ID_STATUS 4210 +#define HK_ID_SERVER 4211 +#define HK_ID_COPYFP 4212 +#define HK_ID_REJECT 4213 +#define HK_ID_DETAILS 4214 +#define HK_DLG_HOST 4301 /* ids inside the two dialogs */ +#define HK_DLG_LIST 4302 +#define HK_DLG_WIDTH 280 + +#define HK_TAB_KNOWN 0 +#define HK_TAB_ANNOUNCED 1 +#define HK_MAX_KEYS 65536u +#define HK_MAX_ANN 1024u +#define HK_ANN_ID_MAX 64 +#define HK_CLIENT_W 1000 +#define HK_CLIENT_H 520 +#define HK_DETAILS_LINES 11 /* host, port, type, two fingerprints, store, line, source, added, comment, the read-only note */ + +typedef struct hk_entry { + ui_hk_row row; + uint8_t *blob; /* public key blob, for sd_known_hosts_remove_key */ + size_t blen; +} hk_entry; + +typedef struct hk_ann { + char id[HK_ANN_ID_MAX]; /* announcement id, echoed back to hostkey.announced.accept */ + char host[UI_HK_HOST_MAX]; + int add; /* 1: key to add, 0: key to remove */ + char type[UI_HK_TYPE_MAX]; + char fp[UI_HK_FP_MAX]; +} hk_ann; + +static struct { + HWND wnd, tab, list_known, list_ann, status; + HWND btn_remove, btn_putty, btn_openssh, btn_refresh, btn_add, btn_addall, btn_server, btn_copyfp, btn_reject; + HWND details; /* read-only text under the Known keys list: the selected key */ + HFONT font; + unsigned dpi; + hk_entry *keys; + size_t nkeys, capkeys; + hk_ann *anns; + size_t nanns, capanns; + int ann_wait; /* hostkey.announced.list in flight */ + int accept_wait; /* hostkey.announced.accept in flight */ + int add_wait; /* hostkey.add in flight */ + int reject_wait; /* hostkey.announced.reject in flight */ + wchar_t reject_host[UI_HK_HOST_MAX]; + wchar_t add_host[UI_HK_HOST_MAX]; /* the server asked for, for the messages */ + wchar_t accept_host[UI_HK_HOST_MAX]; +} g_hk; + +static void update_details(void); + +static int dpx(int v) +{ + return ui_dpi_scale(v, g_hk.dpi); +} + +static void status_text(const wchar_t *text) +{ + if (g_hk.status) + SetWindowTextW(g_hk.status, text ? text : L""); +} + +static void status_msg(sd_msg_id id) +{ + status_text(sd_l10n_get(id)); +} + +static void status_count(sd_msg_id id, size_t n) +{ + wchar_t *t = ui_format_count(id, (long long)n); + if (t != NULL) { + status_text(t); + sd_free(t); + } +} + +static int sel_index(HWND list) +{ + if (list == NULL) + return -1; + return (int)SendMessageW(list, LVM_GETNEXTITEM, (WPARAM)-1, MAKELPARAM(LVNI_SELECTED, 0)); +} + +static LPARAM item_param(HWND list, int idx) +{ + LVITEMW it; + memset(&it, 0, sizeof(it)); + it.mask = LVIF_PARAM; + it.iItem = idx; + if (!SendMessageW(list, LVM_GETITEMW, 0, (LPARAM)&it)) + return -1; + return it.lParam; +} + +static void insert_row(HWND list, int row, wchar_t cells[][UI_HK_CELL_MAX], int ncols, LPARAM param) +{ + LVITEMW it; + int j; + memset(&it, 0, sizeof(it)); + it.mask = LVIF_TEXT | LVIF_PARAM; + it.iItem = row; + it.pszText = cells[0]; + it.lParam = param; + SendMessageW(list, LVM_INSERTITEMW, 0, (LPARAM)&it); + for (j = 1; j < ncols; j++) { + LVITEMW sub; + memset(&sub, 0, sizeof(sub)); + sub.iSubItem = j; + sub.pszText = cells[j]; + SendMessageW(list, LVM_SETITEMTEXTW, (WPARAM)row, (LPARAM)&sub); + } +} + +static void set_columns(void) +{ + static const int known_w[UI_HK_CELLS] = { 170, 50, 90, 250, 130 }; + static const int ann_w[4] = { 170, 120, 90, 250 }; + int i; + if (g_hk.list_known) + for (i = 0; i < UI_HK_CELLS; i++) + SendMessageW(g_hk.list_known, LVM_SETCOLUMNWIDTH, (WPARAM)i, (LPARAM)dpx(known_w[i])); + if (g_hk.list_ann) + for (i = 0; i < 4; i++) + SendMessageW(g_hk.list_ann, LVM_SETCOLUMNWIDTH, (WPARAM)i, (LPARAM)dpx(ann_w[i])); +} + +/* ---- known_hosts access (sd_known_hosts, the stores of 5.1) ---- */ + +static int user_store_path(wchar_t *out, size_t cap) +{ + wchar_t root[MAX_PATH]; + if (sd_config_root(root, SD_COUNTOF(root)) != SD_OK) + return 0; + if (wcslen(root) + 13 >= cap) + return 0; + wcscpy_s(out, cap, root); + wcscat_s(out, cap, L"\\known_hosts"); + return 1; +} + +/* Opens the machine store (read-only), the user store (writable) and, when extra is set, one source. */ +static sd_known_hosts *open_stores(int with_machine, sd_kh_store_kind extra, const wchar_t *extra_path) +{ + sd_kh_store stores[3]; + uint32_t n = 0; + sd_known_hosts *kh = NULL; + static wchar_t user_kh[MAX_PATH]; + if (!user_store_path(user_kh, SD_COUNTOF(user_kh))) + return NULL; + memset(stores, 0, sizeof(stores)); + if (with_machine) { + stores[n].kind = SD_KH_STORE_MACHINE; + stores[n].path = NULL; + stores[n].writable = 0; + n++; + } + stores[n].kind = SD_KH_STORE_USER; + stores[n].path = user_kh; + stores[n].writable = 1; + n++; + if (extra != SD_KH_STORE_MACHINE) { + stores[n].kind = extra; + stores[n].path = extra_path; + stores[n].writable = 0; + n++; + } + if (sd_known_hosts_open(stores, n, &kh) != SD_OK) + return NULL; + return kh; +} + +typedef struct hk_collect { + ui_hk_store store; +} hk_collect; + +static int collect_cb(void *ctx, const sd_kh_all_entry *e) +{ + hk_collect *c = (hk_collect *)ctx; + hk_entry *k; + char fp[UI_HK_FP_MAX]; + char md5[UI_HK_FP_MAX]; + if (g_hk.nkeys >= HK_MAX_KEYS) + return 1; + if (g_hk.nkeys == g_hk.capkeys) { + size_t ncap = g_hk.capkeys ? g_hk.capkeys * 2 : 64; + hk_entry *p = (hk_entry *)sd_realloc(g_hk.keys, ncap * sizeof(hk_entry)); + if (p == NULL) + return 1; + g_hk.keys = p; + g_hk.capkeys = ncap; + } + if (sd_hostkey_fingerprint_sha256(e->blob, e->len, fp) != SD_OK) + strcpy_s(fp, sizeof(fp), "?"); + if (sd_hostkey_fingerprint_md5(e->blob, e->len, md5) != SD_OK) + md5[0] = '\0'; + k = &g_hk.keys[g_hk.nkeys]; + memset(k, 0, sizeof(*k)); + if (ui_hk_row_init(&k->row, c->store, e->pattern, e->hashed, e->type, fp, e->line) != SD_OK) + return 0; + ui_hk_row_set_extras(&k->row, md5, e->comment, e->added, (int)e->origin); + k->blob = (uint8_t *)sd_alloc(e->len); + if (k->blob == NULL) + return 1; + memcpy(k->blob, e->blob, e->len); + k->blen = e->len; + g_hk.nkeys++; + return 0; +} + +static void clear_keys(void) +{ + size_t i; + for (i = 0; i < g_hk.nkeys; i++) + sd_free(g_hk.keys[i].blob); + g_hk.nkeys = 0; +} + +static void clear_anns(void) +{ + g_hk.nanns = 0; +} + +static void populate_known(void) +{ + size_t i; + wchar_t cells[UI_HK_CELLS][UI_HK_CELL_MAX]; + const wchar_t *hashed = sd_l10n_get(SD_MSG_UI_HOSTKEYS_HASHED); + const wchar_t *machine = sd_l10n_get(SD_MSG_UI_HOSTKEYS_SRC_MACHINE); + const wchar_t *user = sd_l10n_get(SD_MSG_UI_HOSTKEYS_SRC_USER); + if (g_hk.list_known == NULL) + return; + SendMessageW(g_hk.list_known, WM_SETREDRAW, FALSE, 0); + SendMessageW(g_hk.list_known, LVM_DELETEALLITEMS, 0, 0); + for (i = 0; i < g_hk.nkeys; i++) { + ui_hk_row_cells(&g_hk.keys[i].row, hashed, machine, user, cells); + insert_row(g_hk.list_known, (int)i, cells, UI_HK_CELLS, (LPARAM)i); + } + SendMessageW(g_hk.list_known, WM_SETREDRAW, TRUE, 0); + InvalidateRect(g_hk.list_known, NULL, TRUE); + update_details(); +} + +static void populate_ann(void) +{ + size_t i; + wchar_t cells[4][UI_HK_CELL_MAX]; + if (g_hk.list_ann == NULL) + return; + SendMessageW(g_hk.list_ann, WM_SETREDRAW, FALSE, 0); + SendMessageW(g_hk.list_ann, LVM_DELETEALLITEMS, 0, 0); + for (i = 0; i < g_hk.nanns; i++) { + const hk_ann *a = &g_hk.anns[i]; + ui_hk_announce_cells(a->host, a->add, a->type, a->fp, sd_l10n_get(SD_MSG_UI_HOSTKEYS_ACTION_ADD), + sd_l10n_get(SD_MSG_UI_HOSTKEYS_ACTION_REMOVE), cells); + insert_row(g_hk.list_ann, (int)i, cells, 4, (LPARAM)i); + } + SendMessageW(g_hk.list_ann, WM_SETREDRAW, TRUE, 0); + InvalidateRect(g_hk.list_ann, NULL, TRUE); +} + +static void refresh_known(void) +{ + sd_known_hosts *kh; + hk_collect c; + clear_keys(); + kh = open_stores(1, SD_KH_STORE_MACHINE, NULL); + if (kh != NULL) { + c.store = UI_HK_STORE_MACHINE; + (void)sd_known_hosts_list_all(kh, SD_KH_STORE_MACHINE, collect_cb, &c); + c.store = UI_HK_STORE_USER; + (void)sd_known_hosts_list_all(kh, SD_KH_STORE_USER, collect_cb, &c); + sd_known_hosts_close(kh); + } else { + status_msg(SD_MSG_UI_HOSTKEYS_STORE_FAILED); + } + populate_known(); + if (kh != NULL) + status_count(SD_MSG_UI_HOSTKEYS_STATUS_KNOWN, g_hk.nkeys); +} + +/* ---- buttons ---- */ + +static int ann_has_removals(const char *id) +{ + size_t i; + for (i = 0; i < g_hk.nanns; i++) + if (!g_hk.anns[i].add && strcmp(g_hk.anns[i].id, id) == 0) + return 1; + return 0; +} + +/* The key selected in the Known keys list, or NULL. */ +static const hk_entry *selected_key(void) +{ + int sel = sel_index(g_hk.list_known); + LPARAM p; + if (sel < 0) + return NULL; + p = item_param(g_hk.list_known, sel); + if (p < 0 || (size_t)p >= g_hk.nkeys) + return NULL; + return &g_hk.keys[p]; +} + +/* The text of the details pane for one key: one line per fact (03 5.1, 7.5). Lines for facts the key's line does not have + (MD5 not computed, no source comment, no added date, no comment) are left out. */ +static void build_details(const ui_hk_row *row, wchar_t *text, size_t cap) +{ + static const sd_msg_id lines[UI_HK_DETAILS] = { + SD_MSG_UI_HOSTKEYS_DET_HOST, SD_MSG_UI_HOSTKEYS_DET_PORT, SD_MSG_UI_HOSTKEYS_DET_TYPE, + SD_MSG_UI_HOSTKEYS_DET_FP, SD_MSG_UI_HOSTKEYS_DET_MD5, SD_MSG_UI_HOSTKEYS_DET_STORE, + SD_MSG_UI_HOSTKEYS_DET_LINE, SD_MSG_UI_HOSTKEYS_DET_ORIGIN, SD_MSG_UI_HOSTKEYS_DET_ADDED, + SD_MSG_UI_HOSTKEYS_DET_COMMENT + }; + const wchar_t *origins[UI_HK_ORIGINS]; + wchar_t values[UI_HK_DETAILS][UI_HK_CELL_MAX]; + size_t i, used = 0; + origins[UI_HK_ORIGIN_OTHER] = L""; + origins[UI_HK_ORIGIN_SHELLDRIVE] = sd_l10n_get(SD_MSG_UI_HOSTKEYS_ORIGIN_SHELLDRIVE); + origins[UI_HK_ORIGIN_PUTTY] = sd_l10n_get(SD_MSG_UI_HOSTKEYS_ORIGIN_PUTTY); + origins[UI_HK_ORIGIN_OPENSSH] = sd_l10n_get(SD_MSG_UI_HOSTKEYS_ORIGIN_OPENSSH); + origins[UI_HK_ORIGIN_SERVER] = sd_l10n_get(SD_MSG_UI_HOSTKEYS_ORIGIN_SERVER); + ui_hk_row_details(row, sd_l10n_get(SD_MSG_UI_HOSTKEYS_HASHED), sd_l10n_get(SD_MSG_UI_HOSTKEYS_SRC_MACHINE), + sd_l10n_get(SD_MSG_UI_HOSTKEYS_SRC_USER), origins, values); + text[0] = 0; + for (i = 0; i < UI_HK_DETAILS; i++) { + wchar_t *line; + size_t n; + if (i == UI_HK_DET_PORT && row->hashed) + continue; /* a hashed entry names no port */ + if (values[i][0] == 0 && i != UI_HK_DET_PORT) + continue; /* a fact this line does not have */ + line = ui_format1(lines[i], "value", values[i]); + if (line == NULL) + continue; + n = wcslen(line); + if (used + n + 3 < cap) { + wcscpy_s(text + used, cap - used, line); + used += n; + text[used++] = L'\r'; + text[used++] = L'\n'; + text[used] = 0; + } + sd_free(line); + } + if (row->store == UI_HK_STORE_MACHINE) + wcsncat_s(text, cap, sd_l10n_get(SD_MSG_UI_HOSTKEYS_DET_READONLY), _TRUNCATE); +} + +/* The details under the list: the selected key. */ +static void update_details(void) +{ + const hk_entry *k = selected_key(); + wchar_t text[HK_DETAILS_LINES * (UI_HK_CELL_MAX + 64)]; + if (g_hk.details == NULL) + return; + if (k == NULL) { + SetWindowTextW(g_hk.details, sd_l10n_get(SD_MSG_UI_HOSTKEYS_DET_NONE)); + return; + } + build_details(&k->row, text, SD_COUNTOF(text)); + SetWindowTextW(g_hk.details, text); +} + +/* --test-windows: the details of a key with a comment, an added date, a source and an MD5 fingerprint have ten lines; a + key without them has the six that the line always has. */ +static int details_smoke(void) +{ + ui_hk_row row; + wchar_t text[HK_DETAILS_LINES * (UI_HK_CELL_MAX + 64)]; + size_t i, lines; + if (ui_hk_row_init(&row, UI_HK_STORE_USER, "example.org", 0, "ssh-ed25519", "SHA256:abc", 3) != SD_OK) + return 0; + build_details(&row, text, SD_COUNTOF(text)); + for (i = 0, lines = 0; text[i] != 0; i++) + if (text[i] == L'\n') + lines++; + if (lines != 6) + return 0; + ui_hk_row_set_extras(&row, "MD5:aa:bb", "imported-from=putty added=2026-10-24", "2026-10-24", UI_HK_ORIGIN_PUTTY); + build_details(&row, text, SD_COUNTOF(text)); + for (i = 0, lines = 0; text[i] != 0; i++) + if (text[i] == L'\n') + lines++; + return lines == 10; +} + +static void copy_fingerprint(void) +{ + const hk_entry *k = selected_key(); + wchar_t fp[UI_HK_FP_MAX]; + if (k == NULL) + return; + ui_text_utf8(k->row.fp, fp, SD_COUNTOF(fp)); + if (ui_win_copy_text(g_hk.wnd, fp, wcslen(fp))) + status_msg(SD_MSG_UI_HOSTKEYS_FP_COPIED); +} + +static void update_buttons(void) +{ + int tab = g_hk.tab ? (int)SendMessageW(g_hk.tab, TCM_GETCURSEL, 0, 0) : HK_TAB_KNOWN; + int sel = sel_index(g_hk.list_known); + int ann_sel = sel_index(g_hk.list_ann); + int removable = 0, ann_ok = 0, has_removals = 0; + if (sel >= 0 && (size_t)sel < g_hk.nkeys) { + LPARAM p = item_param(g_hk.list_known, sel); + if (p >= 0 && (size_t)p < g_hk.nkeys) { + const hk_entry *k = &g_hk.keys[p]; + removable = k->row.removable && k->row.store == UI_HK_STORE_USER; + } + } + if (ann_sel >= 0) { + LPARAM p = item_param(g_hk.list_ann, ann_sel); + if (p >= 0 && (size_t)p < g_hk.nanns) { + ann_ok = !g_hk.ann_wait && !g_hk.accept_wait && !g_hk.add_wait && !g_hk.reject_wait && g_app.agent_up; + has_removals = ann_has_removals(g_hk.anns[p].id); + } + } + if (g_hk.btn_remove) + EnableWindow(g_hk.btn_remove, tab == HK_TAB_KNOWN && removable); + if (g_hk.btn_putty) + EnableWindow(g_hk.btn_putty, tab == HK_TAB_KNOWN); + if (g_hk.btn_openssh) + EnableWindow(g_hk.btn_openssh, tab == HK_TAB_KNOWN); + if (g_hk.btn_server) + EnableWindow(g_hk.btn_server, tab == HK_TAB_KNOWN && g_app.agent_up && !g_hk.add_wait && !g_hk.accept_wait); + if (g_hk.btn_copyfp) + EnableWindow(g_hk.btn_copyfp, tab == HK_TAB_KNOWN && selected_key() != NULL); + if (g_hk.btn_reject) + EnableWindow(g_hk.btn_reject, tab == HK_TAB_ANNOUNCED && ann_ok); + if (g_hk.btn_add) + EnableWindow(g_hk.btn_add, tab == HK_TAB_ANNOUNCED && ann_ok); + if (g_hk.btn_addall) + EnableWindow(g_hk.btn_addall, tab == HK_TAB_ANNOUNCED && ann_ok && has_removals); + if (g_hk.btn_refresh) + EnableWindow(g_hk.btn_refresh, TRUE); +} + +static void show_tab(void) +{ + int tab = (int)SendMessageW(g_hk.tab, TCM_GETCURSEL, 0, 0); + int known = tab == HK_TAB_KNOWN; + ShowWindow(g_hk.list_known, known ? SW_SHOW : SW_HIDE); + ShowWindow(g_hk.list_ann, known ? SW_HIDE : SW_SHOW); + ShowWindow(g_hk.btn_remove, known ? SW_SHOW : SW_HIDE); + ShowWindow(g_hk.btn_putty, known ? SW_SHOW : SW_HIDE); + ShowWindow(g_hk.btn_openssh, known ? SW_SHOW : SW_HIDE); + ShowWindow(g_hk.btn_server, known ? SW_SHOW : SW_HIDE); + ShowWindow(g_hk.btn_copyfp, known ? SW_SHOW : SW_HIDE); + ShowWindow(g_hk.details, known ? SW_SHOW : SW_HIDE); + ShowWindow(g_hk.btn_reject, known ? SW_HIDE : SW_SHOW); + ShowWindow(g_hk.btn_add, known ? SW_HIDE : SW_SHOW); + ShowWindow(g_hk.btn_addall, known ? SW_HIDE : SW_SHOW); +} + +/* ---- layout ---- */ + +static int button_width(HWND btn) +{ + wchar_t text[128]; + int w; + GetWindowTextW(btn, text, SD_COUNTOF(text)); + w = ui_win_text_width(g_hk.font, text) + dpx(24); + return w < dpx(100) ? dpx(100) : w; +} + +static void layout(HWND hwnd) +{ + RECT rc, tabrc; + int m, bh, sh, W, H, by, x, i; + HWND btns[9]; + if (!g_hk.tab || !g_hk.status) + return; + GetClientRect(hwnd, &rc); + W = rc.right - rc.left; + H = rc.bottom - rc.top; + m = dpx(8); + bh = dpx(26); + sh = dpx(20); + MoveWindow(g_hk.status, m, H - m - sh, W - 2 * m > 0 ? W - 2 * m : 0, sh, TRUE); + by = H - m - sh - m - bh; + btns[0] = g_hk.btn_remove; + btns[1] = g_hk.btn_copyfp; + btns[2] = g_hk.btn_putty; + btns[3] = g_hk.btn_openssh; + btns[4] = g_hk.btn_server; + btns[5] = g_hk.btn_add; + btns[6] = g_hk.btn_addall; + btns[7] = g_hk.btn_reject; + btns[8] = g_hk.btn_refresh; + x = m; + for (i = 0; i < 9; i++) { + int visible; + if (!btns[i]) + continue; + visible = (int)SendMessageW(g_hk.tab, TCM_GETCURSEL, 0, 0) == HK_TAB_KNOWN ? (i < 5 || i == 8) + : (i >= 5); + if (!visible) + continue; + MoveWindow(btns[i], x, by, button_width(btns[i]), bh, TRUE); + x += button_width(btns[i]) + m; + } + MoveWindow(g_hk.tab, m, m, W - 2 * m > 0 ? W - 2 * m : 0, by - 2 * m > 0 ? by - 2 * m : 0, TRUE); + GetClientRect(g_hk.tab, &tabrc); + TabCtrl_AdjustRect(g_hk.tab, FALSE, &tabrc); + { + int dh = dpx(HK_DETAILS_LINES * 16 + 8); + int lh = tabrc.bottom - tabrc.top - dh - m; + if (lh < dpx(60)) + lh = dpx(60); + MoveWindow(g_hk.list_known, tabrc.left, tabrc.top, tabrc.right - tabrc.left, lh, TRUE); + MoveWindow(g_hk.details, tabrc.left, tabrc.top + lh + m, tabrc.right - tabrc.left, dh, TRUE); + } + MoveWindow(g_hk.list_ann, tabrc.left, tabrc.top, tabrc.right - tabrc.left, tabrc.bottom - tabrc.top, TRUE); +} + +/* ---- actions ---- */ + +static void remove_selected(void) +{ + int sel = sel_index(g_hk.list_known); + hk_entry *k; + sd_known_hosts *kh; + sd_err e; + wchar_t host[UI_HK_HOST_MAX], type[UI_HK_TYPE_MAX]; + sd_arg args[2]; + wchar_t *text; + LPARAM p; + if (sel < 0) + return; + p = item_param(g_hk.list_known, sel); + if (p < 0 || (size_t)p >= g_hk.nkeys) + return; + k = &g_hk.keys[p]; + if (!k->row.removable || k->row.store != UI_HK_STORE_USER) + return; + ui_text_utf8(k->row.host, host, SD_COUNTOF(host)); + ui_text_utf8(k->row.type, type, SD_COUNTOF(type)); + args[0].name = "host"; + args[0].type = SD_ARG_STR; + args[0].v.s = host; + args[1].name = "type"; + args[1].type = SD_ARG_STR; + args[1].v.s = type; + text = sd_l10n_format_alloc(SD_MSG_UI_HOSTKEYS_CONFIRM_REMOVE, args, 2); + if (text == NULL) + return; + if (MessageBoxW(g_hk.wnd, text, L"ShellDrive", MB_YESNO | MB_ICONQUESTION | MB_DEFBUTTON2) != IDYES) { + sd_free(text); + return; + } + sd_free(text); + kh = open_stores(0, SD_KH_STORE_MACHINE, NULL); + if (kh == NULL) { + status_msg(SD_MSG_UI_HOSTKEYS_STORE_FAILED); + return; + } + e = sd_known_hosts_remove_key(kh, SD_KH_STORE_USER, k->row.lookup, k->blob, k->blen); + sd_known_hosts_close(kh); + if (e != SD_OK) { + ui_win_error(g_hk.wnd, SD_MSG_UI_HOSTKEYS_REMOVE_FAILED, e); + return; + } + status_msg(SD_MSG_UI_HOSTKEYS_REMOVED); + refresh_known(); +} + +static int pick_known_hosts_file(wchar_t *path, size_t cap) +{ + OPENFILENAMEW ofn; + wchar_t filter[160]; + wchar_t title[128]; + wchar_t initial[MAX_PATH]; + const wchar_t *all = sd_l10n_get(SD_MSG_UI_HOSTKEYS_FILE_ALL); + size_t n = wcslen(all); + if (n + 8 >= SD_COUNTOF(filter)) + return 0; + wcscpy_s(filter, SD_COUNTOF(filter), all); + wcscpy_s(filter + n + 1, SD_COUNTOF(filter) - n - 1, L"*.*"); + filter[n + 4] = L'\0'; + filter[n + 5] = L'\0'; + wcscpy_s(title, SD_COUNTOF(title), sd_l10n_get(SD_MSG_UI_HOSTKEYS_PICK_TITLE)); + if (ExpandEnvironmentStringsW(L"%USERPROFILE%\\.ssh", initial, SD_COUNTOF(initial)) == 0) + initial[0] = L'\0'; + path[0] = L'\0'; + memset(&ofn, 0, sizeof(ofn)); + ofn.lStructSize = sizeof(ofn); + ofn.hwndOwner = g_hk.wnd; + ofn.lpstrFilter = filter; + ofn.nFilterIndex = 1; + ofn.lpstrFile = path; + ofn.nMaxFile = (DWORD)cap; + ofn.lpstrInitialDir = initial; + ofn.lpstrTitle = title; + ofn.Flags = OFN_FILEMUSTEXIST | OFN_PATHMUSTEXIST | OFN_HIDEREADONLY; + return GetOpenFileNameW(&ofn) ? 1 : 0; +} + +static void import_keys(sd_kh_store_kind src, const wchar_t *src_path) +{ + sd_known_hosts *kh; + sd_kh_import_report rep; + sd_err e; + kh = open_stores(0, src, src_path); + if (kh == NULL) { + status_msg(SD_MSG_UI_HOSTKEYS_STORE_FAILED); + return; + } + memset(&rep, 0, sizeof(rep)); + e = sd_known_hosts_import(kh, src, SD_KH_STORE_USER, &rep); + sd_known_hosts_close(kh); + if (e != SD_OK) { + ui_win_error(g_hk.wnd, SD_MSG_UI_HOSTKEYS_IMPORT_FAILED, e); + return; + } + if (rep.imported == 0) + status_msg(SD_MSG_UI_HOSTKEYS_IMPORT_NONE); + else + status_count(SD_MSG_UI_HOSTKEYS_IMPORTED, rep.imported); + refresh_known(); +} + +static void import_putty(void) +{ + import_keys(SD_KH_SRC_PUTTY, NULL); +} + +static void import_openssh(void) +{ + wchar_t path[MAX_PATH * 2]; + if (!pick_known_hosts_file(path, SD_COUNTOF(path))) + return; + import_keys(SD_KH_SRC_OPENSSH, path); +} + +/* hostkey.announced.list: the agent answers with every announcement it holds (2.4). */ +static void request_announced(void) +{ + if (!g_app.agent_up) { + clear_anns(); + populate_ann(); + status_msg(SD_MSG_UI_HOSTKEYS_AGENT_DOWN); + update_buttons(); + return; + } + if (g_hk.ann_wait) + return; + if (ui_ipc_call(UI_IPC_ANNOUNCED, "hostkey.announced.list", NULL) == SD_OK) + g_hk.ann_wait = 1; + update_buttons(); +} + +static int valid_ann_id(const char *s) +{ + size_t i, n = strlen(s); + if (n == 0 || n >= HK_ANN_ID_MAX) + return 0; + for (i = 0; i < n; i++) { + char c = s[i]; + if (!((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_' || + c == '.')) + return 0; + } + return 1; +} + +static int add_ann_list(const sd_json *arr, const char *id, const char *host, int add) +{ + size_t i, n = sd_json_count(arr); + for (i = 0; i < n; i++) { + const sd_json *key = sd_json_at(arr, i); + const char *type = sd_json_get_str(key, "type"); + const char *fp = sd_json_get_str(key, "fingerprint"); + hk_ann *a; + if (g_hk.nanns >= HK_MAX_ANN) + return 0; + if (g_hk.nanns == g_hk.capanns) { + size_t ncap = g_hk.capanns ? g_hk.capanns * 2 : 16; + hk_ann *p = (hk_ann *)sd_realloc(g_hk.anns, ncap * sizeof(hk_ann)); + if (p == NULL) + return 0; + g_hk.anns = p; + g_hk.capanns = ncap; + } + a = &g_hk.anns[g_hk.nanns]; + memset(a, 0, sizeof(*a)); + strcpy_s(a->id, sizeof(a->id), id); + strcpy_s(a->host, sizeof(a->host), host); + strcpy_s(a->type, sizeof(a->type), type ? type : "?"); + strcpy_s(a->fp, sizeof(a->fp), fp ? fp : "?"); + a->add = add; + g_hk.nanns++; + } + return 1; +} + +static void parse_announced(const sd_json *result) +{ + const sd_json *arr = sd_json_get(result, "announcements"); + size_t i, n = sd_json_count(arr); + clear_anns(); + for (i = 0; i < n; i++) { + const sd_json *an = sd_json_at(arr, i); + const char *id = sd_json_get_str(an, "id"); + const char *host = sd_json_get_str(an, "host"); + if (id == NULL || !valid_ann_id(id)) + continue; + if (host == NULL || strlen(host) >= UI_HK_HOST_MAX) + host = ""; + if (!add_ann_list(sd_json_get(an, "add"), id, host, 1)) + break; + if (!add_ann_list(sd_json_get(an, "remove"), id, host, 0)) + break; + } +} + +/* hostkey.announced.accept for the announcement id; host (UTF-8) is named in the status line afterwards. */ +static void send_accept(const char *id, int add_only, const char *host) +{ + char params[HK_ANN_ID_MAX + 48]; + if (!ui_hk_id_ok(id)) + return; + sprintf_s(params, sizeof(params), "{\"id\":\"%s\",\"add_only\":%s}", id, add_only ? "true" : "false"); + ui_text_utf8(host, g_hk.accept_host, SD_COUNTOF(g_hk.accept_host)); + if (ui_ipc_call(UI_IPC_ACCEPT, "hostkey.announced.accept", params) == SD_OK) { + g_hk.accept_wait = 1; + update_buttons(); + } else { + status_msg(SD_MSG_UI_HOSTKEYS_ACCEPT_FAILED_QUEUE); + } +} + +static void accept_selected(int add_only) +{ + int sel = sel_index(g_hk.list_ann); + LPARAM p; + const hk_ann *a; + if (sel < 0) + return; + p = item_param(g_hk.list_ann, sel); + if (p < 0 || (size_t)p >= g_hk.nanns) + return; + a = &g_hk.anns[p]; + if (!add_only && ann_has_removals(a->id)) { + if (MessageBoxW(g_hk.wnd, sd_l10n_get(SD_MSG_UI_HOSTKEYS_CONFIRM_ACCEPT_REMOVALS), L"ShellDrive", + MB_YESNO | MB_ICONQUESTION | MB_DEFBUTTON2) != IDYES) + return; + } + send_accept(a->id, add_only, a->host); +} + +/* hostkey.announced.reject: a declined offer leaves the agent's announcements (03 2.4). host (UTF-8) is named in the + status line. Returns 1 when the request is on its way. */ +static int send_reject(const char *id, const char *host) +{ + char params[HK_ANN_ID_MAX + 16]; + if (ui_hk_reject_params(id, params, sizeof(params)) != SD_OK) + return 0; + ui_text_utf8(host != NULL ? host : "", g_hk.reject_host, SD_COUNTOF(g_hk.reject_host)); + if (ui_ipc_call(UI_IPC_HK_REJECT, "hostkey.announced.reject", params) != SD_OK) + return 0; + g_hk.reject_wait = 1; + update_buttons(); + return 1; +} + +static void reject_selected(void) +{ + int sel = sel_index(g_hk.list_ann); + LPARAM p; + const hk_ann *a; + if (sel < 0) + return; + p = item_param(g_hk.list_ann, sel); + if (p < 0 || (size_t)p >= g_hk.nanns) + return; + a = &g_hk.anns[p]; + if (!send_reject(a->id, a->host)) + status_msg(SD_MSG_UI_HOSTKEYS_ACCEPT_FAILED_QUEUE); +} + +/* ---- Add key from server (hostkey.add) ---- */ + +typedef struct hk_ask { + ui_hk_target target; /* the validated server, set on OK */ +} hk_ask; + +static wchar_t g_ask_prefill[UI_HK_SPEC_MAX]; /* the server the next "Add key from server" dialog starts with */ + +static INT_PTR CALLBACK ask_proc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) +{ + hk_ask *c = (hk_ask *)GetWindowLongPtrW(hwnd, DWLP_USER); + switch (msg) { + case WM_INITDIALOG: + SetWindowLongPtrW(hwnd, DWLP_USER, (LONG_PTR)lp); + SendDlgItemMessageW(hwnd, HK_DLG_HOST, EM_LIMITTEXT, UI_HK_SPEC_MAX - 1, 0); + if (g_ask_prefill[0] != 0) { + SetDlgItemTextW(hwnd, HK_DLG_HOST, g_ask_prefill); + g_ask_prefill[0] = 0; + } + return TRUE; + case WM_COMMAND: + if (c == NULL) + break; + if (LOWORD(wp) == IDOK) { + wchar_t text[UI_HK_SPEC_MAX]; + char spec[UI_HK_SPEC_MAX]; + GetDlgItemTextW(hwnd, HK_DLG_HOST, text, SD_COUNTOF(text)); + ui_text_ascii(text, spec, sizeof(spec)); /* a non-ASCII character becomes '?' and is refused */ + if (!ui_hk_target_parse(spec, &c->target)) { + ui_win_message(hwnd, sd_l10n_get(SD_MSG_UI_HOSTKEYS_ADD_INVALID_HOST), MB_OK | MB_ICONWARNING); + SetFocus(GetDlgItem(hwnd, HK_DLG_HOST)); + return TRUE; + } + EndDialog(hwnd, IDOK); + return TRUE; + } + if (LOWORD(wp) == IDCANCEL) { + EndDialog(hwnd, IDCANCEL); + return TRUE; + } + break; + default: + break; + } + return FALSE; +} + +/* Builds the server dialog template. Release *blob with sd_free. */ +static sd_err build_ask(unsigned char **blob, size_t *len) +{ + static const WORD ids[2] = { IDOK, IDCANCEL }; + const wchar_t *texts[2]; + wchar_t face[UI_DLG_FACE_MAX]; + WORD points = 8; + int dpi = 96; + ui_measure m; + ui_dlg *d = (ui_dlg *)sd_calloc(1, sizeof(ui_dlg)); + sd_err e; + if (d == NULL) + return SD_E_NO_MEMORY; + ui_message_font(face, &points, &dpi); + ui_measure_init(&m, face, points, dpi); + texts[0] = sd_l10n_get(SD_MSG_UI_HOSTKEYS_ADD_BTN_OK); + texts[1] = sd_l10n_get(SD_MSG_UI_HOSTKEYS_ADD_BTN_CANCEL); + e = ui_dlg_init(d, sd_l10n_get(SD_MSG_UI_HOSTKEYS_ADD_TITLE), HK_DLG_WIDTH, face, points, ui_measure_cb, &m); + if (e == SD_OK) e = ui_dlg_text(d, 0, sd_l10n_get(SD_MSG_UI_HOSTKEYS_ADD_INTRO), SS_NOPREFIX); + if (e == SD_OK) e = ui_dlg_text(d, 0, sd_l10n_get(SD_MSG_UI_HOSTKEYS_ADD_HOST_LABEL), 0); + if (e == SD_OK) e = ui_dlg_edit(d, HK_DLG_HOST, 0); + if (e == SD_OK) e = ui_dlg_space(d, 4); + if (e == SD_OK) e = ui_dlg_buttons(d, ids, texts, 2, IDOK); + if (e == SD_OK) e = ui_dlg_build(d, blob, len); + ui_measure_free(&m); + sd_free(d); + return e; +} + +/* Asks for the server. 1 when the user chose Look up with a usable name. */ +static int ask_server(HWND owner, ui_hk_target *t) +{ + unsigned char *blob = NULL; + size_t len = 0; + hk_ask c; + INT_PTR r = IDCANCEL; + memset(&c, 0, sizeof(c)); + if (build_ask(&blob, &len) == SD_OK && blob != NULL) + r = DialogBoxIndirectParamW(g_app.inst, (LPCDLGTEMPLATEW)blob, owner, ask_proc, (LPARAM)&c); + sd_free(blob); + if (r != IDOK) + return 0; + *t = c.target; + return 1; +} + +static INT_PTR CALLBACK review_proc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) +{ + (void)lp; + switch (msg) { + case WM_INITDIALOG: + return TRUE; + case WM_COMMAND: + if (LOWORD(wp) == IDOK || LOWORD(wp) == IDCANCEL) { + EndDialog(hwnd, LOWORD(wp)); + return TRUE; + } + break; + default: + break; + } + return FALSE; +} + +/* Builds the review dialog template for an offer. Release *blob with sd_free. */ +static sd_err build_review(const ui_hk_offer *o, unsigned char **blob, size_t *len) +{ + static const WORD ids[2] = { IDOK, IDCANCEL }; + const wchar_t *texts[2]; + wchar_t face[UI_DLG_FACE_MAX]; + WORD points = 8; + int dpi = 96; + ui_measure m; + wchar_t host[UI_HK_HOST_MAX]; + wchar_t lines[UI_HK_OFFER_KEYS * (UI_HK_TYPE_MAX + UI_HK_FP_MAX + 8) + 8]; + wchar_t *intro; + sd_arg a[2]; + ui_dlg *d = (ui_dlg *)sd_calloc(1, sizeof(ui_dlg)); + sd_err e; + if (d == NULL) + return SD_E_NO_MEMORY; + ui_text_utf8(o->host, host, SD_COUNTOF(host)); + a[0].name = "host"; + a[0].type = SD_ARG_STR; + a[0].v.s = host; + a[1].name = "count"; + a[1].type = SD_ARG_INT; + a[1].v.i = (long long)o->nkeys; + intro = sd_l10n_format_alloc(o->verified ? SD_MSG_UI_HOSTKEYS_ADD_REVIEW_VERIFIED : SD_MSG_UI_HOSTKEYS_ADD_REVIEW_FIRST, + a, 2); + ui_hk_offer_lines(o, lines, SD_COUNTOF(lines)); + ui_message_font(face, &points, &dpi); + ui_measure_init(&m, face, points, dpi); + texts[0] = sd_l10n_get(SD_MSG_UI_HOSTKEYS_ADD_REVIEW_BTN_ADD); + texts[1] = sd_l10n_get(SD_MSG_UI_HOSTKEYS_ADD_REVIEW_BTN_SKIP); + e = ui_dlg_init(d, sd_l10n_get(SD_MSG_UI_HOSTKEYS_ADD_REVIEW_TITLE), HK_DLG_WIDTH + 80, face, points, ui_measure_cb, + &m); + if (e == SD_OK) e = ui_dlg_text(d, 0, intro != NULL ? intro : L"", SS_NOPREFIX); + if (e == SD_OK) e = ui_dlg_text(d, 0, sd_l10n_get(SD_MSG_UI_HOSTKEYS_ADD_REVIEW_LIST_LABEL), 0); + if (e == SD_OK) e = ui_dlg_multiline(d, HK_DLG_LIST, lines, UI_HK_OFFER_KEYS); + if (e == SD_OK) e = ui_dlg_space(d, 4); + /* the safe answer is the default button: Enter never adds a key (TM-R03) */ + if (e == SD_OK) e = ui_dlg_buttons(d, ids, texts, 2, IDCANCEL); + if (e == SD_OK) e = ui_dlg_build(d, blob, len); + ui_measure_free(&m); + sd_free(d); + sd_free(intro); + return e; +} + +/* The review of the keys a server offered. 1 when the user chose Add. */ +static int review_offer(HWND owner, const ui_hk_offer *o) +{ + unsigned char *blob = NULL; + size_t len = 0; + INT_PTR r = IDCANCEL; + if (build_review(o, &blob, &len) == SD_OK && blob != NULL) + r = DialogBoxIndirectParamW(g_app.inst, (LPCDLGTEMPLATEW)blob, owner, review_proc, 0); + sd_free(blob); + return r == IDOK; +} + +/* --test-windows: both templates build and parse (no modal loop). */ +int ui_hostkeys_dialogs_smoke(void) +{ + static const char *const k_types[] = { "ssh-ed25519", "ecdsa-sha2-nistp256" }; + unsigned char *blob = NULL; + size_t len = 0, count = 0, offs[UI_DLG_MAX_ITEMS]; + ui_hk_offer o; + size_t i; + int ok; + ok = build_ask(&blob, &len) == SD_OK && blob != NULL && + ui_dlg_parse_check(blob, len, &count, offs, SD_COUNTOF(offs)) == SD_OK && count == 5; + sd_free(blob); + blob = NULL; + memset(&o, 0, sizeof(o)); + strcpy_s(o.host, sizeof(o.host), "example.org"); + strcpy_s(o.id, sizeof(o.id), "a-1"); + for (i = 0; i < 2; i++) { + strcpy_s(o.keys[i].type, sizeof(o.keys[i].type), k_types[i]); + strcpy_s(o.keys[i].fp, sizeof(o.keys[i].fp), "SHA256:abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG"); + } + o.nkeys = 2; + for (i = 0; i < 2 && ok; i++) { + o.verified = (int)i; + ok = build_review(&o, &blob, &len) == SD_OK && blob != NULL && + ui_dlg_parse_check(blob, len, &count, offs, SD_COUNTOF(offs)) == SD_OK && count == 5; + sd_free(blob); + blob = NULL; + } + return ok && details_smoke(); +} + +static void add_from_server(void) +{ + ui_hk_target t; + char params[UI_HK_HOST_MAX * 2 + 96]; + wchar_t *text; + if (!g_app.agent_up) { + status_msg(SD_MSG_UI_HOSTKEYS_AGENT_DOWN); + return; + } + if (g_hk.add_wait || g_hk.accept_wait || !ask_server(g_hk.wnd, &t)) + return; + if (ui_hk_add_params(&t, NULL, params, sizeof(params)) != SD_OK) + return; + ui_text_utf8(t.host, g_hk.add_host, SD_COUNTOF(g_hk.add_host)); + if (ui_ipc_call(UI_IPC_HK_ADD, "hostkey.add", params) != SD_OK) { + status_msg(SD_MSG_UI_HOSTKEYS_ACCEPT_FAILED_QUEUE); + return; + } + g_hk.add_wait = 1; + text = ui_format1(SD_MSG_UI_HOSTKEYS_ADD_WORKING, "host", g_hk.add_host); + if (text != NULL) { + status_text(text); + sd_free(text); + } + update_buttons(); +} + +static void add_failed(sd_err err) +{ + wchar_t code[64]; + sd_arg a[2]; + wchar_t *text; + sd_msg_id id; + switch (ui_hk_add_fail_of(err)) { + case UI_HK_ADD_FAIL_AGENT_DOWN: + status_msg(SD_MSG_UI_HOSTKEYS_AGENT_DOWN); + return; + case UI_HK_ADD_FAIL_BUSY: + status_msg(SD_MSG_UI_HOSTKEYS_ACCEPT_FAILED_QUEUE); + return; + case UI_HK_ADD_FAIL_UNSUPPORTED: + status_text(L""); + ui_win_message(g_hk.wnd, sd_l10n_get(SD_MSG_UI_HOSTKEYS_ADD_ERR_UNSUPPORTED), MB_OK | MB_ICONINFORMATION); + return; + case UI_HK_ADD_FAIL_MISMATCH: + id = SD_MSG_UI_HOSTKEYS_ADD_ERR_MISMATCH; + break; + default: + id = SD_MSG_UI_HOSTKEYS_ADD_ERR_CONNECT; + break; + } + ui_text_utf8(sd_err_name(err), code, SD_COUNTOF(code)); + a[0].name = "host"; + a[0].type = SD_ARG_STR; + a[0].v.s = g_hk.add_host; + a[1].name = "code"; + a[1].type = SD_ARG_STR; + a[1].v.s = code; + text = sd_l10n_format_alloc(id, a, 2); + status_text(L""); + if (text != NULL) { + ui_win_message(g_hk.wnd, text, MB_OK | MB_ICONERROR); + sd_free(text); + } +} + +/* The reply of hostkey.add: show the offered keys and, on Add, store them with hostkey.announced.accept. */ +static void on_add_reply(sd_err err, const sd_json *result) +{ + ui_hk_offer o; + wchar_t *text; + g_hk.add_wait = 0; + if (err != SD_OK || result == NULL || ui_hk_offer_parse(result, &o) != SD_OK) { + add_failed(err != SD_OK ? err : SD_E_PROTOCOL); + return; + } + if (o.id[0] == '\0' || o.nkeys == 0) { + text = ui_format1(SD_MSG_UI_HOSTKEYS_ADD_NONE, "host", g_hk.add_host); + if (text != NULL) { + status_text(text); + sd_free(text); + } + return; + } + if (review_offer(g_hk.wnd, &o)) { + send_accept(o.id, 1, o.host[0] != '\0' ? o.host : ""); + } else { + status_msg(SD_MSG_UI_HOSTKEYS_ADD_DECLINED); + /* the refused offer leaves the announcements; the reply refreshes the list (an agent without the method keeps it) */ + if (!send_reject(o.id, o.host)) + request_announced(); + } +} + +/* ---- replies and events (ui_win.c) ---- */ + +void ui_hostkeys_on_reply(int kind, sd_err err, const sd_json *result) +{ + if (g_hk.wnd == NULL) + return; + if (kind == UI_IPC_HK_ADD) { + on_add_reply(err, result); + } else if (kind == UI_IPC_ANNOUNCED) { + g_hk.ann_wait = 0; + if (err == SD_OK && result != NULL) { + parse_announced(result); + } else { + clear_anns(); + status_msg(err == SD_E_DISCONNECTED ? SD_MSG_UI_HOSTKEYS_AGENT_DOWN : SD_MSG_UI_HOSTKEYS_ANNOUNCED_FAILED); + } + populate_ann(); + if (err == SD_OK) + status_count(SD_MSG_UI_HOSTKEYS_STATUS_ANNOUNCED, g_hk.nanns); + } else if (kind == UI_IPC_HK_REJECT) { + wchar_t *t; + g_hk.reject_wait = 0; + if (err != SD_OK) { + wchar_t code[64]; + ui_text_utf8(sd_err_name(err), code, SD_COUNTOF(code)); + t = ui_format1(SD_MSG_UI_HOSTKEYS_REJECT_FAILED, "code", code); + } else { + t = ui_format1(SD_MSG_UI_HOSTKEYS_REJECTED, "host", g_hk.reject_host); + } + if (t != NULL) { + status_text(t); + sd_free(t); + } + request_announced(); + } else if (kind == UI_IPC_ACCEPT) { + g_hk.accept_wait = 0; + if (err != SD_OK) { + ui_win_error(g_hk.wnd, SD_MSG_UI_HOSTKEYS_ACCEPT_FAILED, err); + } else { + wchar_t *t; + sd_arg a; + a.name = "host"; + a.type = SD_ARG_STR; + a.v.s = g_hk.accept_host; + t = sd_l10n_format_alloc(SD_MSG_UI_HOSTKEYS_ACCEPTED, &a, 1); + if (t != NULL) { + status_text(t); + sd_free(t); + } + refresh_known(); /* the user store changed */ + request_announced(); + } + } + update_buttons(); +} + +void ui_hostkeys_on_event(void) +{ + if (g_hk.wnd != NULL) + request_announced(); +} + +void ui_hostkeys_on_agent_state(int up) +{ + if (g_hk.wnd == NULL) + return; + if (!up) { + clear_anns(); + g_hk.ann_wait = 0; + g_hk.accept_wait = 0; + g_hk.add_wait = 0; + g_hk.reject_wait = 0; + populate_ann(); + } + if (up) + request_announced(); + update_buttons(); +} + +/* ---- window ---- */ + +static void create_children(HWND hwnd) +{ + LVCOLUMNW col; + int i; + static const sd_msg_id known_cols[UI_HK_CELLS] = { + SD_MSG_UI_HOSTKEYS_COL_HOST, SD_MSG_UI_HOSTKEYS_COL_PORT, SD_MSG_UI_HOSTKEYS_COL_TYPE, + SD_MSG_UI_HOSTKEYS_COL_FP, SD_MSG_UI_HOSTKEYS_COL_SOURCE + }; + static const sd_msg_id ann_cols[4] = { + SD_MSG_UI_HOSTKEYS_COL_HOST, SD_MSG_UI_HOSTKEYS_COL_CHANGE, SD_MSG_UI_HOSTKEYS_COL_TYPE, + SD_MSG_UI_HOSTKEYS_COL_FP + }; + HINSTANCE inst = g_app.inst; + + g_hk.tab = CreateWindowExW(0, WC_TABCONTROLW, L"", WS_CHILD | WS_VISIBLE | WS_TABSTOP, 0, 0, 10, 10, hwnd, + (HMENU)(UINT_PTR)HK_ID_TAB, inst, NULL); + g_hk.list_known = CreateWindowExW(WS_EX_CLIENTEDGE, WC_LISTVIEWW, L"", + WS_CHILD | WS_VISIBLE | WS_TABSTOP | LVS_REPORT | LVS_SINGLESEL | + LVS_SHOWSELALWAYS, + 0, 0, 10, 10, hwnd, (HMENU)(UINT_PTR)HK_ID_LIST_KNOWN, inst, NULL); + g_hk.list_ann = CreateWindowExW(WS_EX_CLIENTEDGE, WC_LISTVIEWW, L"", + WS_CHILD | WS_TABSTOP | LVS_REPORT | LVS_SINGLESEL | LVS_SHOWSELALWAYS, 0, 0, + 10, 10, hwnd, (HMENU)(UINT_PTR)HK_ID_LIST_ANN, inst, NULL); + g_hk.btn_remove = CreateWindowExW(0, L"BUTTON", sd_l10n_get(SD_MSG_UI_HOSTKEYS_BTN_REMOVE), + WS_CHILD | WS_TABSTOP | BS_PUSHBUTTON, 0, 0, 10, 10, hwnd, + (HMENU)(UINT_PTR)HK_ID_REMOVE, inst, NULL); + g_hk.btn_putty = CreateWindowExW(0, L"BUTTON", sd_l10n_get(SD_MSG_UI_HOSTKEYS_BTN_IMPORT_PUTTY), + WS_CHILD | WS_TABSTOP | BS_PUSHBUTTON, 0, 0, 10, 10, hwnd, + (HMENU)(UINT_PTR)HK_ID_PUTTY, inst, NULL); + g_hk.btn_openssh = CreateWindowExW(0, L"BUTTON", sd_l10n_get(SD_MSG_UI_HOSTKEYS_BTN_IMPORT_OPENSSH), + WS_CHILD | WS_TABSTOP | BS_PUSHBUTTON, 0, 0, 10, 10, hwnd, + (HMENU)(UINT_PTR)HK_ID_OPENSSH, inst, NULL); + g_hk.btn_server = CreateWindowExW(0, L"BUTTON", sd_l10n_get(SD_MSG_UI_HOSTKEYS_BTN_ADD_SERVER), + WS_CHILD | WS_TABSTOP | BS_PUSHBUTTON, 0, 0, 10, 10, hwnd, + (HMENU)(UINT_PTR)HK_ID_SERVER, inst, NULL); + g_hk.btn_copyfp = CreateWindowExW(0, L"BUTTON", sd_l10n_get(SD_MSG_UI_HOSTKEYS_BTN_COPY_FP), + WS_CHILD | WS_TABSTOP | BS_PUSHBUTTON, 0, 0, 10, 10, hwnd, + (HMENU)(UINT_PTR)HK_ID_COPYFP, inst, NULL); + g_hk.btn_reject = CreateWindowExW(0, L"BUTTON", sd_l10n_get(SD_MSG_UI_HOSTKEYS_BTN_REJECT), + WS_CHILD | WS_TABSTOP | BS_PUSHBUTTON, 0, 0, 10, 10, hwnd, + (HMENU)(UINT_PTR)HK_ID_REJECT, inst, NULL); + g_hk.details = CreateWindowExW(WS_EX_CLIENTEDGE, L"EDIT", L"", + WS_CHILD | WS_VISIBLE | WS_TABSTOP | WS_VSCROLL | ES_MULTILINE | ES_READONLY | + ES_AUTOVSCROLL | ES_NOHIDESEL, + 0, 0, 10, 10, hwnd, (HMENU)(UINT_PTR)HK_ID_DETAILS, inst, NULL); + g_hk.btn_add = CreateWindowExW(0, L"BUTTON", sd_l10n_get(SD_MSG_UI_HOSTKEYS_BTN_ADD), + WS_CHILD | WS_TABSTOP | BS_PUSHBUTTON, 0, 0, 10, 10, hwnd, + (HMENU)(UINT_PTR)HK_ID_ADD, inst, NULL); + g_hk.btn_addall = CreateWindowExW(0, L"BUTTON", sd_l10n_get(SD_MSG_UI_HOSTKEYS_BTN_ADD_ALL), + WS_CHILD | WS_TABSTOP | BS_PUSHBUTTON, 0, 0, 10, 10, hwnd, + (HMENU)(UINT_PTR)HK_ID_ADDALL, inst, NULL); + g_hk.btn_refresh = CreateWindowExW(0, L"BUTTON", sd_l10n_get(SD_MSG_UI_MAIN_REFRESH), + WS_CHILD | WS_VISIBLE | WS_TABSTOP | BS_PUSHBUTTON, 0, 0, 10, 10, hwnd, + (HMENU)(UINT_PTR)HK_ID_REFRESH, inst, NULL); + g_hk.status = CreateWindowExW(0, L"STATIC", L"", WS_CHILD | WS_VISIBLE | SS_LEFT | SS_NOPREFIX, 0, 0, 10, 10, + hwnd, (HMENU)(UINT_PTR)HK_ID_STATUS, inst, NULL); + + SendMessageW(g_hk.list_known, LVM_SETEXTENDEDLISTVIEWSTYLE, 0, LVS_EX_FULLROWSELECT | LVS_EX_LABELTIP); + SendMessageW(g_hk.list_ann, LVM_SETEXTENDEDLISTVIEWSTYLE, 0, LVS_EX_FULLROWSELECT | LVS_EX_LABELTIP); + for (i = 0; i < UI_HK_CELLS; i++) { + memset(&col, 0, sizeof(col)); + col.mask = LVCF_TEXT | LVCF_SUBITEM | LVCF_WIDTH; + col.pszText = (LPWSTR)sd_l10n_get(known_cols[i]); + col.iSubItem = i; + col.cx = 100; + SendMessageW(g_hk.list_known, LVM_INSERTCOLUMNW, (WPARAM)i, (LPARAM)&col); + } + for (i = 0; i < 4; i++) { + memset(&col, 0, sizeof(col)); + col.mask = LVCF_TEXT | LVCF_SUBITEM | LVCF_WIDTH; + col.pszText = (LPWSTR)sd_l10n_get(ann_cols[i]); + col.iSubItem = i; + col.cx = 100; + SendMessageW(g_hk.list_ann, LVM_INSERTCOLUMNW, (WPARAM)i, (LPARAM)&col); + } + { + static const sd_msg_id tabs[2] = { SD_MSG_UI_HOSTKEYS_TAB_KNOWN, SD_MSG_UI_HOSTKEYS_TAB_ANNOUNCED }; + for (i = 0; i < 2; i++) { + TCITEMW it; + memset(&it, 0, sizeof(it)); + it.mask = TCIF_TEXT; + it.pszText = (LPWSTR)sd_l10n_get(tabs[i]); + SendMessageW(g_hk.tab, TCM_INSERTITEMW, (WPARAM)i, (LPARAM)&it); + } + } + ui_win_apply_font(hwnd, g_hk.font); + set_columns(); + show_tab(); + update_details(); +} + +static void on_dpi(HWND hwnd, unsigned dpi, const RECT *suggested) +{ + HFONT old = g_hk.font; + if (dpi == 0) + dpi = UI_DPI_BASE; + g_hk.dpi = dpi; + if (suggested) + SetWindowPos(hwnd, NULL, suggested->left, suggested->top, suggested->right - suggested->left, + suggested->bottom - suggested->top, SWP_NOZORDER | SWP_NOACTIVATE); + g_hk.font = ui_win_font(dpi); + ui_win_apply_font(hwnd, g_hk.font); + if (old) + DeleteObject(old); + set_columns(); + layout(hwnd); +} + +static void on_command(WORD id, WORD code) +{ + if (code != BN_CLICKED) + return; + switch (id) { + case HK_ID_REMOVE: + remove_selected(); + break; + case HK_ID_PUTTY: + import_putty(); + break; + case HK_ID_OPENSSH: + import_openssh(); + break; + case HK_ID_SERVER: + add_from_server(); + break; + case HK_ID_COPYFP: + copy_fingerprint(); + break; + case HK_ID_REJECT: + reject_selected(); + break; + case HK_ID_REFRESH: + refresh_known(); + request_announced(); + break; + case HK_ID_ADD: + accept_selected(1); + break; + case HK_ID_ADDALL: + accept_selected(0); + break; + default: + break; + } + update_buttons(); +} + +static LRESULT CALLBACK hk_proc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) +{ + switch (msg) { + case WM_CREATE: + g_hk.wnd = hwnd; + g_hk.dpi = ui_dpi_window(hwnd); + g_hk.font = ui_win_font(g_hk.dpi); + create_children(hwnd); + layout(hwnd); + update_buttons(); + return 0; + case WM_SIZE: + layout(hwnd); + return 0; + case WM_COMMAND: + on_command(LOWORD(wp), HIWORD(wp)); + return 0; + case WM_NOTIFY: { + const NMHDR *h = (const NMHDR *)lp; + if (h->idFrom == HK_ID_TAB && h->code == TCN_SELCHANGE) { + show_tab(); + update_buttons(); + } else if ((h->idFrom == HK_ID_LIST_KNOWN || h->idFrom == HK_ID_LIST_ANN) && h->code == LVN_ITEMCHANGED) { + if (h->idFrom == HK_ID_LIST_KNOWN) + update_details(); + update_buttons(); + } + return 0; + } + case WM_CLOSE: + DestroyWindow(hwnd); + return 0; + case WM_DPICHANGED: + on_dpi(hwnd, (unsigned)LOWORD(wp), (const RECT *)lp); + return 0; + case WM_DESTROY: + if (g_hk.font) + DeleteObject(g_hk.font); + clear_keys(); + sd_free(g_hk.keys); + sd_free(g_hk.anns); + memset(&g_hk, 0, sizeof(g_hk)); + return 0; + default: + break; + } + return DefWindowProcW(hwnd, msg, wp, lp); +} + +static HWND create_window(int visible) +{ + wchar_t cls[320]; + HWND w; + ui_win_class_name(L"hostkeys", cls); + if (!ui_win_register(g_app.inst, cls, hk_proc)) + return NULL; + if (g_hk.wnd != NULL && IsWindow(g_hk.wnd)) + return g_hk.wnd; + w = CreateWindowExW(0, cls, sd_l10n_get(SD_MSG_UI_HOSTKEYS_TITLE), WS_OVERLAPPEDWINDOW | WS_CLIPCHILDREN, + CW_USEDEFAULT, CW_USEDEFAULT, HK_CLIENT_W, HK_CLIENT_H, NULL, NULL, g_app.inst, NULL); + if (w == NULL) + return NULL; + SetWindowPos(w, NULL, 0, 0, ui_dpi_scale(HK_CLIENT_W, ui_dpi_window(w)), ui_dpi_scale(HK_CLIENT_H, + ui_dpi_window(w)), SWP_NOMOVE | SWP_NOZORDER | SWP_NOACTIVATE); + if (visible) + ShowWindow(w, SW_SHOW); + return w; +} + +void ui_hostkeys_open(void) +{ + HWND w; + if (g_hk.wnd != NULL && IsWindow(g_hk.wnd)) { + ShowWindow(g_hk.wnd, IsIconic(g_hk.wnd) ? SW_RESTORE : SW_SHOW); + SetForegroundWindow(g_hk.wnd); + return; + } + w = create_window(1); + if (w == NULL) + return; + refresh_known(); + request_announced(); +} + +/* Opens the manager and selects the first key of host (a changed key or a wrong key type led here, 5.4, TM-R04). */ +void ui_hostkeys_open_for(const char *host) +{ + size_t i; + ui_hostkeys_open(); + if (host == NULL || host[0] == 0 || g_hk.list_known == NULL) + return; + for (i = 0; i < g_hk.nkeys; i++) { + if (_stricmp(g_hk.keys[i].row.host, host) == 0) { + ListView_SetItemState(g_hk.list_known, (int)i, LVIS_SELECTED | LVIS_FOCUSED, LVIS_SELECTED | LVIS_FOCUSED); + ListView_EnsureVisible(g_hk.list_known, (int)i, FALSE); + break; + } + } + if (g_hk.tab != NULL && SendMessageW(g_hk.tab, TCM_GETCURSEL, 0, 0) != HK_TAB_KNOWN) { + SendMessageW(g_hk.tab, TCM_SETCURSEL, HK_TAB_KNOWN, 0); + show_tab(); + } +} + +/* The wrong key type dialog (5.2): opens the manager at host and starts "Add key from server" for target (the name the + key was looked up under, or the host). The review runs after this call returns, so a modal dialog that called it is + closed first. */ +void ui_hostkeys_add_for(const char *host, const char *target) +{ + ui_hostkeys_open_for(host); + if (g_hk.wnd == NULL || !IsWindow(g_hk.wnd)) + return; + g_ask_prefill[0] = 0; + if (target != NULL && target[0] != 0) + ui_text_utf8(target, g_ask_prefill, SD_COUNTOF(g_ask_prefill)); + PostMessageW(g_hk.wnd, WM_COMMAND, MAKEWPARAM(HK_ID_SERVER, BN_CLICKED), 0); +} + +void ui_hostkeys_close(void) +{ + if (g_hk.wnd != NULL && IsWindow(g_hk.wnd)) + DestroyWindow(g_hk.wnd); +} + +HWND ui_hostkeys_create_hidden(void) +{ + return create_window(0); +} diff --git a/apps/shelldrive-ui/ui_hostkeys_model.c b/apps/shelldrive-ui/ui_hostkeys_model.c new file mode 100644 index 0000000..1b38f07 --- /dev/null +++ b/apps/shelldrive-ui/ui_hostkeys_model.c @@ -0,0 +1,429 @@ +/* shelldrive-ui: rows of the host key manager (03 §5.1, §5.6). See ui_hostkeys_model.h. + * + * Pure: no window or process calls. */ +#include +#include +#include +#include + +#include "ui_hostkeys_model.h" + +#define UI_HK_DEFAULT_PORT 22u + +static void put_ascii(wchar_t *dst, size_t cap, const char *src) +{ + size_t i; + if (cap == 0) + return; + for (i = 0; i + 1 < cap && src != NULL && src[i] != '\0'; i++) + dst[i] = (src[i] >= 0x20 && src[i] < 0x7f) ? (wchar_t)(unsigned char)src[i] : L'?'; + dst[i] = L'\0'; +} + +static void put_wide(wchar_t *dst, size_t cap, const wchar_t *src) +{ + if (cap == 0) + return; + if (src == NULL) + src = L""; + wcsncpy_s(dst, cap, src, _TRUNCATE); +} + +static int is_digit(char c) +{ + return c >= '0' && c <= '9'; +} + +/* "[host]:port" with a port 1..65535. */ +static int parse_bracketed(const char *p, char *host, size_t host_cap, unsigned *port) +{ + const char *close = strchr(p, ']'); + size_t hl, i; + unsigned long v = 0; + if (p[0] != '[' || close == NULL) + return 0; + hl = (size_t)(close - (p + 1)); + if (hl == 0 || hl + 1 > host_cap || close[1] != ':' || !is_digit(close[2])) + return 0; + for (i = 2; close[i] != '\0'; i++) { + if (!is_digit(close[i]) || v > 65535) + return 0; + v = v * 10u + (unsigned long)(close[i] - '0'); + } + if (v == 0 || v > 65535) + return 0; + memcpy(host, p + 1, hl); + host[hl] = '\0'; + *port = (unsigned)v; + return 1; +} + +sd_err ui_hk_row_init(ui_hk_row *row, ui_hk_store store, const char *pattern, int hashed, const char *type, + const char *fp, uint32_t line) +{ + size_t pl; + if (row == NULL || pattern == NULL || type == NULL || fp == NULL) + return SD_E_INVALID_PARAM; + if (store != UI_HK_STORE_MACHINE && store != UI_HK_STORE_USER) + return SD_E_INVALID_PARAM; + pl = strlen(pattern); + if (pl == 0 || pl >= UI_HK_HOST_MAX || strlen(type) >= UI_HK_TYPE_MAX || strlen(fp) >= UI_HK_FP_MAX) + return SD_E_INVALID_PARAM; + + memset(row, 0, sizeof(*row)); + row->store = store; + row->line = line; + row->hashed = hashed ? 1 : 0; + strcpy_s(row->type, sizeof(row->type), type); + strcpy_s(row->fp, sizeof(row->fp), fp); + strcpy_s(row->pattern, sizeof(row->pattern), pattern); + row->port = UI_HK_DEFAULT_PORT; + + if (row->hashed || pattern[0] == '|') { + row->hashed = 1; + return SD_OK; + } + if (strchr(pattern, ',') != NULL || strchr(pattern, '*') != NULL || strchr(pattern, '?') != NULL || + strchr(pattern, '!') != NULL) { + /* a list or a wildcard: shown as written, removed only through the file editor */ + strcpy_s(row->host, sizeof(row->host), pattern); + return SD_OK; + } + if (pattern[0] == '[') { + if (!parse_bracketed(pattern, row->host, sizeof(row->host), &row->port)) + return SD_OK; /* malformed: shown as written, not removable */ + strcpy_s(row->lookup, sizeof(row->lookup), pattern); + row->removable = 1; + return SD_OK; + } + strcpy_s(row->host, sizeof(row->host), pattern); + strcpy_s(row->lookup, sizeof(row->lookup), pattern); + row->removable = 1; + return SD_OK; +} + +void ui_hk_row_cells(const ui_hk_row *row, const wchar_t *hashed_text, const wchar_t *machine_text, + const wchar_t *user_text, wchar_t cells[UI_HK_CELLS][UI_HK_CELL_MAX]) +{ + memset(cells, 0, sizeof(wchar_t) * UI_HK_CELLS * UI_HK_CELL_MAX); + if (row->hashed) { + put_wide(cells[0], UI_HK_CELL_MAX, hashed_text); + cells[1][0] = L'\0'; + } else { + put_ascii(cells[0], UI_HK_CELL_MAX, row->host); + swprintf_s(cells[1], UI_HK_CELL_MAX, L"%u", row->port); + } + put_ascii(cells[2], UI_HK_CELL_MAX, row->type); + put_ascii(cells[3], UI_HK_CELL_MAX, row->fp); + put_wide(cells[4], UI_HK_CELL_MAX, row->store == UI_HK_STORE_MACHINE ? machine_text : user_text); +} + +void ui_hk_row_set_extras(ui_hk_row *row, const char *md5, const char *comment, const char *added, int origin) +{ + size_t i; + for (i = 0; md5 != NULL && md5[i] != '\0' && i + 1 < sizeof(row->md5); i++) + row->md5[i] = md5[i]; + row->md5[i] = '\0'; + for (i = 0; comment != NULL && comment[i] != '\0' && i + 1 < sizeof(row->comment); i++) + row->comment[i] = comment[i]; + row->comment[i] = '\0'; + for (i = 0; added != NULL && added[i] != '\0' && i + 1 < sizeof(row->added); i++) + row->added[i] = added[i]; + row->added[i] = '\0'; + row->origin = (origin >= 0 && origin < UI_HK_ORIGINS) ? origin : UI_HK_ORIGIN_OTHER; +} + +void ui_hk_row_details(const ui_hk_row *row, const wchar_t *hashed_text, const wchar_t *machine_text, + const wchar_t *user_text, const wchar_t *const origin_text[UI_HK_ORIGINS], + wchar_t values[UI_HK_DETAILS][UI_HK_CELL_MAX]) +{ + wchar_t cells[UI_HK_CELLS][UI_HK_CELL_MAX]; + memset(values, 0, sizeof(wchar_t) * UI_HK_DETAILS * UI_HK_CELL_MAX); + ui_hk_row_cells(row, hashed_text, machine_text, user_text, cells); + memcpy(values[UI_HK_DET_HOST], cells[0], sizeof(cells[0])); + memcpy(values[UI_HK_DET_PORT], cells[1], sizeof(cells[1])); + memcpy(values[UI_HK_DET_TYPE], cells[2], sizeof(cells[2])); + memcpy(values[UI_HK_DET_FP], cells[3], sizeof(cells[3])); + memcpy(values[UI_HK_DET_STORE], cells[4], sizeof(cells[4])); + put_ascii(values[UI_HK_DET_MD5], UI_HK_CELL_MAX, row->md5); + swprintf_s(values[UI_HK_DET_LINE], UI_HK_CELL_MAX, L"%lu", (unsigned long)row->line); + if (row->origin > UI_HK_ORIGIN_OTHER && row->origin < UI_HK_ORIGINS && origin_text != NULL) + put_wide(values[UI_HK_DET_ORIGIN], UI_HK_CELL_MAX, origin_text[row->origin]); + put_ascii(values[UI_HK_DET_ADDED], UI_HK_CELL_MAX, row->added); + put_ascii(values[UI_HK_DET_COMMENT], UI_HK_CELL_MAX, row->comment); +} + +sd_err ui_hk_reject_params(const char *id, char *out, size_t cap) +{ + int n; + if (out == NULL || cap == 0) + return SD_E_INVALID_PARAM; + out[0] = '\0'; + if (!ui_hk_id_ok(id)) + return SD_E_INVALID_PARAM; + if (cap < strlen(id) + sizeof("{\"id\":\"\"}")) /* sprintf_s would abort on a short buffer */ + return SD_E_INVALID_PARAM; + n = sprintf_s(out, cap, "{\"id\":\"%s\"}", id); + if (n < 0) { + out[0] = '\0'; + return SD_E_INVALID_PARAM; + } + return SD_OK; +} + +void ui_hk_announce_cells(const char *host, int action, const char *type, const char *fp, + const wchar_t *add_text, const wchar_t *remove_text, + wchar_t cells[4][UI_HK_CELL_MAX]) +{ + memset(cells, 0, sizeof(wchar_t) * 4 * UI_HK_CELL_MAX); + put_ascii(cells[0], UI_HK_CELL_MAX, host); + put_wide(cells[1], UI_HK_CELL_MAX, action ? add_text : remove_text); + put_ascii(cells[2], UI_HK_CELL_MAX, type); + put_ascii(cells[3], UI_HK_CELL_MAX, fp); +} + +/* ---- "Add key from server..." ---- */ + +static int host_char_ok(char c, int v6) +{ + return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || is_digit(c) || c == '-' || c == '.' || c == '_' || + (v6 && c == ':'); +} + +/* Parses a port: 1..65535, digits only. */ +static int parse_port(const char *s, unsigned *port) +{ + unsigned long v = 0; + size_t i; + if (s[0] == '\0' || strlen(s) > 5) + return 0; + for (i = 0; s[i] != '\0'; i++) { + if (!is_digit(s[i])) + return 0; + v = v * 10 + (unsigned long)(s[i] - '0'); + } + if (v < 1 || v > 65535) + return 0; + *port = (unsigned)v; + return 1; +} + +/* /analyze tracks buf as holding only the n copied bytes and misses the terminator memset leaves after them, so it + reports the string scans below (C6385, C6054); buf is zeroed in full and n < sizeof(buf). */ +#pragma warning(push) +#pragma warning(disable: 6385 6054) +int ui_hk_target_parse(const char *spec, ui_hk_target *t) +{ + char buf[UI_HK_HOST_MAX + 16]; + char *p, *end, *colon; + size_t n, i, ncolon = 0; + int v6 = 0; + unsigned port = UI_HK_DEFAULT_PORT; + if (t != NULL) + memset(t, 0, sizeof(*t)); + if (spec == NULL || t == NULL) + return 0; + while (*spec == ' ' || *spec == '\t') + spec++; + n = strlen(spec); + while (n > 0 && (spec[n - 1] == ' ' || spec[n - 1] == '\t')) + n--; + if (n == 0 || n >= sizeof(buf)) + return 0; + memset(buf, 0, sizeof(buf)); /* the whole buffer is defined (and terminated) for the scans below */ + memcpy(buf, spec, n); + p = buf; + end = buf + n; + if (p[0] == '[') { + char *close = strchr(p, ']'); + if (close == NULL || close == p + 1) + return 0; + *close = '\0'; + if (close[1] == ':') { + if (!parse_port(close + 2, &port)) + return 0; + } else if (close + 1 != end) { + return 0; + } + p++; + v6 = 1; + } else { + for (i = 0; p[i] != '\0'; i++) + if (p[i] == ':') + ncolon++; + if (ncolon == 1) { + colon = strchr(p, ':'); + *colon = '\0'; + if (!parse_port(colon + 1, &port)) + return 0; + } else if (ncolon > 1) { + v6 = 1; /* a bare IPv6 address: no port */ + } + } + n = strlen(p); + if (n == 0 || n >= UI_HK_HOST_MAX) + return 0; + /* copy up to the terminator (n bounds it; indexing by n directly trips /analyze C6385) */ + for (i = 0; i < n && p[i] != '\0'; i++) { + if (!host_char_ok(p[i], v6)) + return 0; + t->host[i] = (p[i] >= 'A' && p[i] <= 'Z') ? (char)(p[i] - 'A' + 'a') : p[i]; + } + t->host[i] = '\0'; + if (t->host[0] == '.' || t->host[0] == '-') + return 0; + t->port = port; + if (port == UI_HK_DEFAULT_PORT) + strcpy_s(t->lookup, sizeof(t->lookup), t->host); + else + sprintf_s(t->lookup, sizeof(t->lookup), "[%s]:%u", t->host, port); + return 1; +} +#pragma warning(pop) + +int ui_hk_type_ok(const char *type) +{ + size_t i, n; + if (type == NULL) + return 0; + n = strlen(type); + if (n == 0 || n >= 64) + return 0; + for (i = 0; i < n; i++) { + char c = type[i]; + if (!((c >= 'a' && c <= 'z') || is_digit(c) || c == '-' || c == '@' || c == '.')) + return 0; + } + return 1; +} + +sd_err ui_hk_add_params(const ui_hk_target *t, const char *type, char *out, size_t cap) +{ + int n; + if (out == NULL || cap == 0) + return SD_E_INVALID_PARAM; + out[0] = '\0'; + if (t == NULL || t->host[0] == '\0' || t->lookup[0] == '\0' || t->port < 1 || t->port > 65535) + return SD_E_INVALID_PARAM; + /* host and lookup hold letters, digits and "-._:[]" only (ui_hk_target_parse), so no JSON escaping is needed */ + if (type != NULL && type[0] != '\0') { + if (!ui_hk_type_ok(type)) + return SD_E_INVALID_PARAM; + n = _snprintf_s(out, cap, _TRUNCATE, "{\"host\":\"%s\",\"port\":%u,\"lookup\":\"%s\",\"type\":\"%s\"}", t->host, + t->port, t->lookup, type); + } else { + n = _snprintf_s(out, cap, _TRUNCATE, "{\"host\":\"%s\",\"port\":%u,\"lookup\":\"%s\"}", t->host, t->port, + t->lookup); + } + if (n < 0) { + out[0] = '\0'; + return SD_E_INVALID_PARAM; + } + return SD_OK; +} + +int ui_hk_id_ok(const char *id) +{ + size_t i, n; + if (id == NULL) + return 0; + n = strlen(id); + if (n == 0 || n >= UI_HK_ID_MAX) + return 0; + for (i = 0; i < n; i++) { + char c = id[i]; + if (!((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || is_digit(c) || c == '-' || c == '_' || c == '.')) + return 0; + } + return 1; +} + +/* Copies s as printable ASCII, a bad character becomes '?'; cut at cap - 1. */ +static void copy_printable(char *dst, size_t cap, const char *s) +{ + size_t i; + for (i = 0; i + 1 < cap && s != NULL && s[i] != '\0'; i++) + dst[i] = (s[i] >= 0x20 && s[i] < 0x7f) ? s[i] : '?'; + dst[i] = '\0'; +} + +sd_err ui_hk_offer_parse(const sd_json *reply, ui_hk_offer *o) +{ + const sd_json *keys; + const char *id, *host; + size_t i, n; + int verified = 0; + if (o != NULL) + memset(o, 0, sizeof(*o)); + if (o == NULL || reply == NULL || sd_json_type_of(reply) != SD_JSON_OBJECT) + return SD_E_INVALID_PARAM; + host = sd_json_get_str(reply, "host"); + copy_printable(o->host, sizeof(o->host), host ? host : ""); + sd_json_get_bool(reply, "verified", &verified); + o->verified = verified != 0; + id = sd_json_get_str(reply, "id"); + if (!ui_hk_id_ok(id)) + return SD_OK; /* nothing was kept: no offer */ + keys = sd_json_get(reply, "keys"); + n = sd_json_count(keys); + for (i = 0; i < n && o->nkeys < UI_HK_OFFER_KEYS; i++) { + const sd_json *k = sd_json_at(keys, i); + const char *type = sd_json_get_str(k, "type"); + const char *fp = sd_json_get_str(k, "fingerprint"); + if (type == NULL || fp == NULL || type[0] == '\0' || fp[0] == '\0') + continue; + copy_printable(o->keys[o->nkeys].type, sizeof(o->keys[0].type), type); + copy_printable(o->keys[o->nkeys].fp, sizeof(o->keys[0].fp), fp); + o->nkeys++; + } + if (o->nkeys > 0) + strcpy_s(o->id, sizeof(o->id), id); + return SD_OK; +} + +void ui_hk_offer_lines(const ui_hk_offer *o, wchar_t *out, size_t cap) +{ + size_t i, len = 0; + if (out == NULL || cap == 0) + return; + out[0] = L'\0'; + if (o == NULL) + return; + for (i = 0; i < o->nkeys; i++) { + wchar_t line[UI_HK_TYPE_MAX + UI_HK_FP_MAX + 8]; + size_t ll; + put_ascii(line, SD_COUNTOF(line), o->keys[i].type); + wcscat_s(line, SD_COUNTOF(line), L" "); + ll = wcslen(line); + put_ascii(line + ll, SD_COUNTOF(line) - ll, o->keys[i].fp); + ll = wcslen(line); + if (len + ll + 3 > cap) + break; + if (i > 0) { + out[len++] = L'\r'; + out[len++] = L'\n'; + } + memcpy(out + len, line, ll * sizeof(wchar_t)); + len += ll; + out[len] = L'\0'; + } +} + +ui_hk_add_fail ui_hk_add_fail_of(sd_err e) +{ + switch (e) { + case SD_E_DISCONNECTED: + case SD_E_SHUTDOWN: + return UI_HK_ADD_FAIL_AGENT_DOWN; + case SD_E_UNSUPPORTED: + return UI_HK_ADD_FAIL_UNSUPPORTED; + case SD_E_HOSTKEY_MISMATCH: + return UI_HK_ADD_FAIL_MISMATCH; + case SD_E_HOSTKEY_REJECTED: + return UI_HK_ADD_FAIL_REJECTED; + case SD_E_BUSY: + return UI_HK_ADD_FAIL_BUSY; + default: + return UI_HK_ADD_FAIL_OTHER; + } +} diff --git a/apps/shelldrive-ui/ui_hostkeys_model.h b/apps/shelldrive-ui/ui_hostkeys_model.h new file mode 100644 index 0000000..5edcf24 --- /dev/null +++ b/apps/shelldrive-ui/ui_hostkeys_model.h @@ -0,0 +1,154 @@ +/* shelldrive-ui: rows of the host key manager (03 §5.1, §5.6). + * + * A row is one plain key of a user or machine known_hosts store, read by sd_known_hosts_list_all. This module + * decides what the row shows (host, port, key type, fingerprint, source) and whether it can be removed by name: + * a hashed entry, a comma list or a wildcard pattern names no single host, so it is listed but cannot be removed + * from this window. The announcement rows of the "Announced keys" tab are formatted here too. + * + * Pure: no window or process calls, so the unit tests (tests/ui) build this file on its own. */ +#ifndef SD_UI_HOSTKEYS_MODEL_H +#define SD_UI_HOSTKEYS_MODEL_H + +#include + +#include "sd/base.h" +#include "sd_base/sd_err.h" +#include "sd_base/sd_json.h" + +#define UI_HK_HOST_MAX 256 +#define UI_HK_TYPE_MAX 48 +#define UI_HK_FP_MAX 64 +#define UI_HK_COMMENT_MAX 256 +#define UI_HK_ADDED_MAX 16 +#define UI_HK_CELLS 5 /* host, port, key type, SHA256 fingerprint, source */ +#define UI_HK_CELL_MAX 300 + +/* Store of a row: the machine store (read-only here) or the user store (writable). */ +typedef enum ui_hk_store { UI_HK_STORE_MACHINE = 0, UI_HK_STORE_USER = 1 } ui_hk_store; + +typedef struct ui_hk_row { + ui_hk_store store; + char pattern[UI_HK_HOST_MAX]; /* the host field as written, or "" when hashed */ + int hashed; /* "|1|salt|hash" entry: the name is not readable */ + char host[UI_HK_HOST_MAX]; /* host name for display ("" when hashed or a list) */ + unsigned port; /* 22 unless the pattern is "[host]:port" */ + int removable; /* 1: a single host name that sd_known_hosts_remove_key can address */ + char lookup[UI_HK_HOST_MAX + 8]; /* lookup name for removal: "host" or "[host]:port" */ + char type[UI_HK_TYPE_MAX]; + char fp[UI_HK_FP_MAX]; + uint32_t line; + char md5[UI_HK_FP_MAX]; /* "MD5:xx:..." for the details pane ("" when not computed) */ + char comment[UI_HK_COMMENT_MAX]; /* the trailing comment of the line, "" if none */ + char added[UI_HK_ADDED_MAX]; /* "YYYY-MM-DD" from the added= member of the comment, or "" */ + int origin; /* ui_hk_origin, the order of sd_kh_origin (sd_known_hosts_list_all) */ +} ui_hk_row; + +/* Where a line came from (03 5.1, 7.5). The values are those of sd_kh_origin so the window can pass them on as they are. */ +typedef enum ui_hk_origin { + UI_HK_ORIGIN_OTHER = 0, /* another tool, or a hand-written line */ + UI_HK_ORIGIN_SHELLDRIVE, /* by=shelldrive, or a bare added= */ + UI_HK_ORIGIN_PUTTY, /* imported-from=putty: "Copied from PuTTY" */ + UI_HK_ORIGIN_OPENSSH, /* imported-from=openssh: "Copied from OpenSSH" */ + UI_HK_ORIGIN_SERVER, /* updated-by=hostkeys-00: "Updated by server" */ + UI_HK_ORIGINS +} ui_hk_origin; + +/* Sets what sd_known_hosts_list_all reports beyond the key itself: the MD5 fingerprint, the trailing comment, the added + date and the origin. Text longer than the fields is cut; an origin out of range is "other". NULL is "". */ +void ui_hk_row_set_extras(ui_hk_row *row, const char *md5, const char *comment, const char *added, int origin); + +/* Fills the derived fields of a row from its store, pattern, key type, fingerprint and line. */ +sd_err ui_hk_row_init(ui_hk_row *row, ui_hk_store store, const char *pattern, int hashed, const char *type, + const char *fp, uint32_t line); + +/* The five cells of a row as text. hashed_text: the host cell of a hashed entry ("(hashed)"); + machine_text / user_text: the source cell. Each cell is at most UI_HK_CELL_MAX - 1 characters. */ +void ui_hk_row_cells(const ui_hk_row *row, const wchar_t *hashed_text, const wchar_t *machine_text, + const wchar_t *user_text, wchar_t cells[UI_HK_CELLS][UI_HK_CELL_MAX]); + +/* Details of the selected key (the pane under the Known keys list): host, port, key type, SHA256 and MD5 fingerprints, + store, line in its file, the source (origin of the line), the date it was added and the comment of the line. A hashed + entry shows hashed_text as the host and no port. origin_text[o]: the text of ui_hk_origin o ("ShellDrive", "Copied from + PuTTY", ...). The fields the line does not have (MD5 not computed, origin other, no added date, no comment) are empty, + and the window leaves their lines out. */ +#define UI_HK_DETAILS 10 +typedef enum ui_hk_detail { UI_HK_DET_HOST = 0, UI_HK_DET_PORT, UI_HK_DET_TYPE, UI_HK_DET_FP, UI_HK_DET_MD5, + UI_HK_DET_STORE, UI_HK_DET_LINE, UI_HK_DET_ORIGIN, UI_HK_DET_ADDED, + UI_HK_DET_COMMENT } ui_hk_detail; +void ui_hk_row_details(const ui_hk_row *row, const wchar_t *hashed_text, const wchar_t *machine_text, + const wchar_t *user_text, const wchar_t *const origin_text[UI_HK_ORIGINS], + wchar_t values[UI_HK_DETAILS][UI_HK_CELL_MAX]); + +/* The hostkey.announced.reject request {"id": ...} for an announcement the user declined (03 2.4). Fails with + SD_E_INVALID_PARAM for an id that ui_hk_id_ok refuses or when cap is too small. */ +sd_err ui_hk_reject_params(const char *id, char *out, size_t cap); + +/* Announcement row (the "Announced keys" tab): one key to add or to remove. action: 1 = add, 0 = remove. */ +void ui_hk_announce_cells(const char *host, int action, const char *type, const char *fp, + const wchar_t *add_text, const wchar_t *remove_text, + wchar_t cells[4][UI_HK_CELL_MAX]); + +/* ---- "Add key from server..." (03 §5.2, §6.1 hostkey add --from-server; agent IPC hostkey.add) ---- */ + +#define UI_HK_OFFER_KEYS 8 /* the agent keeps at most 8 keys of one host */ +#define UI_HK_ID_MAX 64 +#define UI_HK_SPEC_MAX 300 /* the server field as typed, UTF-16 units */ + +/* The server the user typed, reduced to what the agent needs. */ +typedef struct ui_hk_target { + char host[UI_HK_HOST_MAX]; /* lower case, ASCII (an IDN name is typed in its xn-- form) */ + unsigned port; /* 1..65535, 22 when none was typed */ + char lookup[UI_HK_HOST_MAX + 8]; /* known_hosts name: "host", or "[host]:port" when the port is not 22 */ +} ui_hk_target; + +/* Reads "host", "host:port", "[v6]" or "[v6]:port" (surrounding blanks ignored). A bare IPv6 address with several + colons is a host without a port. Rejects an empty field, a user@ prefix, characters outside letters, digits and + "-._" (":" only inside an IPv6 address), a port outside 1..65535 and a name that is too long. 1 on success. */ +int ui_hk_target_parse(const char *spec, ui_hk_target *t); + +/* The key types the dialog may restrict the lookup to (an empty type means any). 1 when type is acceptable to the + agent: lower case letters, digits and "-@." only, 1..63 characters. */ +int ui_hk_type_ok(const char *type); + +/* The hostkey.add request object for t, with the optional key type (NULL or "" for any). Fails with + SD_E_INVALID_PARAM when t or type is not valid or cap is too small. */ +sd_err ui_hk_add_params(const ui_hk_target *t, const char *type, char *out, size_t cap); + +typedef struct ui_hk_offer_key { + char type[UI_HK_TYPE_MAX]; + char fp[UI_HK_FP_MAX]; +} ui_hk_offer_key; + +/* The reply of hostkey.add without "expect": the keys the server presented and the announcement that holds them. */ +typedef struct ui_hk_offer { + char host[UI_HK_HOST_MAX]; + int verified; /* 1: a stored key of the host matched in this connection (not a first contact) */ + char id[UI_HK_ID_MAX]; /* announcement id for hostkey.announced.accept; "" when the server offered nothing */ + size_t nkeys; + ui_hk_offer_key keys[UI_HK_OFFER_KEYS]; +} ui_hk_offer; + +/* Reads a hostkey.add reply. Text is reduced to printable ASCII (a bad character becomes '?'). A reply without an id, + or with an id outside [A-Za-z0-9._-], has no offer (id "" and nkeys 0). SD_E_INVALID_PARAM for a reply that is not + an object. */ +sd_err ui_hk_offer_parse(const sd_json *reply, ui_hk_offer *o); + +/* 1 when id is usable in a hostkey.announced.accept request. */ +int ui_hk_id_ok(const char *id); + +/* One line per key for the review dialog: " ", lines joined with CR LF (an edit control). */ +void ui_hk_offer_lines(const ui_hk_offer *o, wchar_t *out, size_t cap); + +/* What went wrong with a hostkey.add call, for the message to show. */ +typedef enum ui_hk_add_fail { + UI_HK_ADD_FAIL_AGENT_DOWN = 0, /* the agent is not running or the pipe broke */ + UI_HK_ADD_FAIL_UNSUPPORTED, /* the agent build cannot collect keys from a server yet (not_implemented) */ + UI_HK_ADD_FAIL_MISMATCH, /* the server's key does not match the stored one (host_key_mismatch) */ + UI_HK_ADD_FAIL_REJECTED, /* host_key_rejected */ + UI_HK_ADD_FAIL_BUSY, /* the request could not be queued or the agent is busy */ + UI_HK_ADD_FAIL_OTHER /* connect failed, timeout and the rest: shown with the error name */ +} ui_hk_add_fail; + +ui_hk_add_fail ui_hk_add_fail_of(sd_err e); + +#endif /* SD_UI_HOSTKEYS_MODEL_H */ diff --git a/apps/shelldrive-ui/ui_icon.c b/apps/shelldrive-ui/ui_icon.c new file mode 100644 index 0000000..3a33bce --- /dev/null +++ b/apps/shelldrive-ui/ui_icon.c @@ -0,0 +1,149 @@ +/* shelldrive-ui: icons drawn in code (original art, no image files). Each state has its own shape as + * well as a colour (03 §7.2): idle is a ring, connected a filled disc, warning a filled diamond. + * + * A colour icon is a 32-bpp DIB (alpha unused, the mask decides transparency) plus a 1-bpp AND mask, + * which CreateIconIndirect accepts on XP and later. */ +#include +#include + +#include "sd_base/sd_mem.h" +#include "ui_int.h" + +#define UI_ICON_MAX_SIZE 64 + +/* Icons are never destroyed while the process runs: the window class and the tray keep using them. One + entry per (kind, size); a DPI change adds an entry for the new size. */ +#define UI_ICON_SLOTS 32 +static struct { + HICON icon; + int kind; + int size; +} g_cache[UI_ICON_SLOTS]; + +static unsigned rgb_for(ui_icon_kind kind) +{ + switch (kind) { + case UI_ICON_CONNECTED: return 0x000078D4u; /* blue */ + case UI_ICON_WARNING: return 0x00F29F05u; /* amber */ + default: return 0x009A9A9Au; /* grey */ + } +} + +/* Inside test on doubled coordinates: the circle has radius size - 2 (in doubled units). */ +static int inside(ui_icon_kind kind, int dx, int dy, int size) +{ + int r = size - 2; + int d2 = dx * dx + dy * dy; + int thick = 2 * (size / 8 > 0 ? size / 8 : 1); + int ri = r - thick; + switch (kind) { + case UI_ICON_CONNECTED: + return d2 <= r * r; + case UI_ICON_WARNING: { + int adx = dx < 0 ? -dx : dx, ady = dy < 0 ? -dy : dy; + return adx + ady <= r; + } + default: + return d2 <= r * r && d2 >= ri * ri; + } +} + +static HICON draw_icon(ui_icon_kind kind, int size) +{ + BITMAPINFO bmi; + void *bits = NULL; + HDC dc; + HBITMAP color, mask; + ICONINFO info; + HICON icon = NULL; + int x, y, mask_stride; + unsigned char *mbits; + unsigned rgb = rgb_for(kind); + uint32_t *px; + + memset(&bmi, 0, sizeof(bmi)); + bmi.bmiHeader.biSize = sizeof(BITMAPINFOHEADER); + bmi.bmiHeader.biWidth = size; + bmi.bmiHeader.biHeight = -size; /* top-down */ + bmi.bmiHeader.biPlanes = 1; + bmi.bmiHeader.biBitCount = 32; + bmi.bmiHeader.biCompression = BI_RGB; + + dc = GetDC(NULL); + color = CreateDIBSection(dc, &bmi, DIB_RGB_COLORS, &bits, NULL, 0); + ReleaseDC(NULL, dc); + if (color == NULL || bits == NULL) { + if (color) + DeleteObject(color); + return NULL; + } + px = (uint32_t *)bits; + + mask_stride = ((size + 15) / 16) * 2; /* 1-bpp rows are WORD aligned */ + mbits = sd_alloc((size_t)mask_stride * (size_t)size); + if (mbits == NULL) { + DeleteObject(color); + return NULL; + } + memset(mbits, 0xFF, (size_t)mask_stride * (size_t)size); /* 1 = transparent */ + + for (y = 0; y < size; y++) { + for (x = 0; x < size; x++) { + int dx = 2 * x - (size - 1), dy = 2 * y - (size - 1); + if (inside(kind, dx, dy, size)) { + px[y * size + x] = 0xFF000000u | rgb; + mbits[y * mask_stride + x / 8] &= (unsigned char)~(0x80u >> (x % 8)); + } else { + px[y * size + x] = 0; + } + } + } + + mask = CreateBitmap(size, size, 1, 1, mbits); + sd_free(mbits); + if (mask != NULL) { + memset(&info, 0, sizeof(info)); + info.fIcon = TRUE; + info.hbmMask = mask; + info.hbmColor = color; + icon = CreateIconIndirect(&info); + DeleteObject(mask); + } + DeleteObject(color); + return icon; +} + +HICON ui_icon_get(ui_icon_kind kind, int size) +{ + size_t i; + unsigned k = (unsigned)kind; + if (k >= UI_ICON_COUNT) + k = UI_ICON_IDLE; + if (size < 8) + size = 8; + if (size > UI_ICON_MAX_SIZE) + size = UI_ICON_MAX_SIZE; + for (i = 0; i < UI_ICON_SLOTS; i++) + if (g_cache[i].icon != NULL && g_cache[i].kind == (int)k && g_cache[i].size == size) + return g_cache[i].icon; + for (i = 0; i < UI_ICON_SLOTS; i++) { + if (g_cache[i].icon == NULL) { + g_cache[i].icon = draw_icon((ui_icon_kind)k, size); + g_cache[i].kind = (int)k; + g_cache[i].size = size; + return g_cache[i].icon; + } + } + return NULL; +} + +void ui_icon_free_all(void) +{ + size_t i; + for (i = 0; i < UI_ICON_SLOTS; i++) { + if (g_cache[i].icon) + DestroyIcon(g_cache[i].icon); + g_cache[i].icon = NULL; + g_cache[i].size = 0; + } +} diff --git a/apps/shelldrive-ui/ui_import.c b/apps/shelldrive-ui/ui_import.c new file mode 100644 index 0000000..390eeac --- /dev/null +++ b/apps/shelldrive-ui/ui_import.c @@ -0,0 +1,275 @@ +/* shelldrive-ui: the import dialog (03 §3.7, §7.5): PuTTY sessions or the OpenSSH config file, read by sd_config's + * importers, shown as a preview with one check box per connection. Candidates that already exist are unchecked + * (sd_import_mark_duplicates); entries the importer skipped are listed with the reason and cannot be checked. + * Nothing is written until "Import" (sd_import_write), then the profiles are read again and the agent is told. */ +#include +#include +#include +#include + +#include "sd_base/sd_mem.h" +#include "sd_config/sd_import.h" +#include "sd_l10n/sd_l10n.h" +#include "sd_l10n_ui_ids.h" +#include "ui_dlg.h" +#include "ui_int.h" +#include "ui_measure.h" + +#define IMP_WIDTH 340 +#define IMP_LIST 1600 /* the list view (created over the placeholder edit) */ +#define IMP_PUTTY 1601 +#define IMP_OPENSSH 1602 +#define IMP_RUN 1603 +#define IMP_SKIP_BIT 0x80000000u /* LVITEM lParam: a skipped entry, low bits = its index */ +#define IMP_COLS 4 + +typedef struct imp_ctx { + HWND dlg; + HWND list; + sd_import_result res; + int loaded; +} imp_ctx; + +static void set_list_columns(HWND list) +{ + static const sd_msg_id titles[IMP_COLS] = { SD_MSG_UI_IMPORT_COL_NAME, SD_MSG_UI_IMPORT_COL_SERVER, + SD_MSG_UI_IMPORT_COL_USER, SD_MSG_UI_IMPORT_COL_NOTE }; + static const int widths[IMP_COLS] = { 120, 120, 70, 150 }; + int i; + for (i = 0; i < IMP_COLS; i++) { + LVCOLUMNW col; + memset(&col, 0, sizeof(col)); + col.mask = LVCF_TEXT | LVCF_WIDTH | LVCF_SUBITEM; + col.pszText = (LPWSTR)sd_l10n_get(titles[i]); + col.cx = widths[i]; + col.iSubItem = i; + SendMessageW(list, LVM_INSERTCOLUMNW, (WPARAM)i, (LPARAM)&col); + } +} + +/* One row: the cells of the candidate or the skipped entry, and its check state. */ +static void add_row(HWND list, int row, const wchar_t *cells[IMP_COLS], LPARAM tag, int checked, int checkable) +{ + LVITEMW it; + int j; + memset(&it, 0, sizeof(it)); + it.mask = LVIF_TEXT | LVIF_PARAM; + it.iItem = row; + it.lParam = tag; + it.pszText = (LPWSTR)cells[0]; + SendMessageW(list, LVM_INSERTITEMW, 0, (LPARAM)&it); + for (j = 1; j < IMP_COLS; j++) { + LVITEMW sub; + memset(&sub, 0, sizeof(sub)); + sub.iItem = row; + sub.iSubItem = j; + sub.pszText = (LPWSTR)cells[j]; + SendMessageW(list, LVM_SETITEMTEXTW, (WPARAM)row, (LPARAM)&sub); + } + if (checkable) + ListView_SetCheckState(list, row, checked); +} + +/* Fills the list from the importer result: candidates first (checked when selected), then the skipped entries. */ +static void fill_list(imp_ctx *c) +{ + uint32_t i; + int row = 0; + SendMessageW(c->list, LVM_DELETEALLITEMS, 0, 0); + for (i = 0; i < c->res.count; i++) { + const sd_import_candidate *cand = &c->res.items[i]; + wchar_t name[160], server[300], user[160], note[200]; + const wchar_t *cells[IMP_COLS]; + wchar_t host[256]; + note[0] = 0; + ui_text_utf8(cand->p.name, name, SD_COUNTOF(name)); + ui_text_utf8(cand->p.host, host, SD_COUNTOF(host)); + wcsncpy_s(server, SD_COUNTOF(server), host, _TRUNCATE); + if (cand->p.port != 22 && cand->p.port != 0) { + wchar_t num[12]; + wsprintfW(num, L":%u", (unsigned)cand->p.port); + wcscat_s(server, SD_COUNTOF(server), num); + } + ui_text_utf8(cand->p.user, user, SD_COUNTOF(user)); + if (cand->flags & SD_IMPORT_F_DUPLICATE) + wcscpy_s(note, SD_COUNTOF(note), sd_l10n_get(SD_MSG_UI_IMPORT_NOTE_DUPLICATE)); + else if (cand->flags & SD_IMPORT_F_ONLY_THIS_KEY) + wcscpy_s(note, SD_COUNTOF(note), sd_l10n_get(SD_MSG_UI_IMPORT_NOTE_ONLY_KEY)); + cells[0] = name; + cells[1] = server; + cells[2] = user; + cells[3] = note; + add_row(c->list, row, cells, (LPARAM)i, cand->selected, 1); + row++; + } + for (i = 0; i < c->res.nskipped; i++) { + const sd_import_skip *sk = &c->res.skipped[i]; + wchar_t src[160], item[160], note[240]; + const wchar_t *cells[IMP_COLS]; + sd_arg a[1]; + wchar_t *formatted; + ui_text_utf8(sk->source, src, SD_COUNTOF(src)); + ui_text_utf8(sk->item, item, SD_COUNTOF(item)); + a[0].name = "item"; + a[0].type = SD_ARG_STR; + a[0].v.s = item; + formatted = sd_l10n_format_alloc(SD_MSG_UI_IMPORT_NOTE_SKIPPED, a, 1); + wcsncpy_s(note, SD_COUNTOF(note), formatted ? formatted : L"", _TRUNCATE); + sd_free(formatted); + cells[0] = src; + cells[1] = L""; + cells[2] = L""; + cells[3] = note; + add_row(c->list, row, cells, (LPARAM)(IMP_SKIP_BIT | i), 0, 0); + row++; + } +} + +/* Reads the chosen source into c->res. */ +static void load_source(imp_ctx *c, int putty) +{ + sd_import_env env; + sd_err e; + sd_import_result_free(&c->res); + sd_import_result_init(&c->res); + memset(&env, 0, sizeof(env)); + if (putty) + e = sd_import_putty_registry(HKEY_CURRENT_USER, NULL, &c->res); + else + e = sd_import_ssh_config_file(NULL, &env, &c->res); + if (e != SD_OK && c->res.count == 0 && c->res.nskipped == 0) { + MessageBoxW(c->dlg, sd_l10n_get(putty ? SD_MSG_UI_IMPORT_ERROR_PUTTY : SD_MSG_UI_IMPORT_ERROR_OPENSSH), + L"ShellDrive", MB_OK | MB_ICONWARNING); + c->loaded = 0; + SendMessageW(c->list, LVM_DELETEALLITEMS, 0, 0); + return; + } + sd_import_mark_duplicates(&c->res, NULL); + c->loaded = 1; + fill_list(c); +} + +/* Writes the checked candidates (sd_import_write) and reloads the profiles. */ +static int import_checked(imp_ctx *c) +{ + int count = ListView_GetItemCount(c->list), row; + uint32_t written = 0; + sd_err e; + if (!c->loaded) + return 0; + for (row = 0; row < count; row++) { + LVITEMW it; + memset(&it, 0, sizeof(it)); + it.mask = LVIF_PARAM; + it.iItem = row; + SendMessageW(c->list, LVM_GETITEMW, 0, (LPARAM)&it); + if (!(it.lParam & (LPARAM)IMP_SKIP_BIT) && (uint32_t)it.lParam < c->res.count) + c->res.items[it.lParam].selected = ListView_GetCheckState(c->list, row) ? 1 : 0; + } + e = sd_import_write(&c->res, &written); + if (e != SD_OK && written == 0) { + MessageBoxW(c->dlg, sd_l10n_get(SD_MSG_UI_ERROR_SAVE), L"ShellDrive", MB_OK | MB_ICONERROR); + return 0; + } + if (written > 0) { + ui_main_reload_profiles(); + ui_ipc_config_reload(); + } + return 1; +} + +static INT_PTR CALLBACK import_proc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) +{ + imp_ctx *c = (imp_ctx *)GetWindowLongPtrW(hwnd, DWLP_USER); + switch (msg) { + case WM_INITDIALOG: { + RECT rc; + HWND placeholder = GetDlgItem(hwnd, IMP_LIST); + c = (imp_ctx *)lp; + SetWindowLongPtrW(hwnd, DWLP_USER, (LONG_PTR)c); + c->dlg = hwnd; + if (placeholder == NULL) + return FALSE; + GetWindowRect(placeholder, &rc); + MapWindowPoints(NULL, hwnd, (LPPOINT)&rc, 2); + DestroyWindow(placeholder); + c->list = CreateWindowExW(WS_EX_CLIENTEDGE, L"SysListView32", L"", + WS_CHILD | WS_VISIBLE | WS_TABSTOP | LVS_REPORT | LVS_SINGLESEL | LVS_SHOWSELALWAYS, + rc.left, rc.top, rc.right - rc.left, rc.bottom - rc.top, hwnd, + (HMENU)(UINT_PTR)IMP_LIST, g_app.inst, NULL); + if (c->list == NULL) + return FALSE; + SendMessageW(c->list, LVM_SETEXTENDEDLISTVIEWSTYLE, 0, LVS_EX_CHECKBOXES | LVS_EX_FULLROWSELECT | LVS_EX_LABELTIP); + set_list_columns(c->list); + SendMessageW(c->list, WM_SETFONT, SendMessageW(hwnd, WM_GETFONT, 0, 0), MAKELPARAM(TRUE, 0)); + return TRUE; + } + case WM_COMMAND: + if (c == NULL) + break; + switch (LOWORD(wp)) { + case IMP_PUTTY: + load_source(c, 1); + return TRUE; + case IMP_OPENSSH: + load_source(c, 0); + return TRUE; + case IMP_RUN: + if (import_checked(c)) + EndDialog(hwnd, IDOK); + return TRUE; + case IDCANCEL: + EndDialog(hwnd, IDCANCEL); + return TRUE; + default: + break; + } + break; + default: + break; + } + return FALSE; +} + +void ui_import_run(HWND owner) +{ + wchar_t face[UI_DLG_FACE_MAX]; + WORD points = 8; + int dpi = 96; + ui_measure m; + ui_dlg *d; + unsigned char *blob = NULL; + size_t len = 0; + imp_ctx c; + sd_err e; + static const WORD src_ids[2] = { IMP_PUTTY, IMP_OPENSSH }; + static const WORD run_ids[2] = { IMP_RUN, IDCANCEL }; + const wchar_t *src_texts[2]; + const wchar_t *run_texts[2]; + + memset(&c, 0, sizeof(c)); + sd_import_result_init(&c.res); + d = (ui_dlg *)sd_calloc(1, sizeof(ui_dlg)); + if (d == NULL) + return; + ui_message_font(face, &points, &dpi); + ui_measure_init(&m, face, points, dpi); + src_texts[0] = sd_l10n_get(SD_MSG_UI_IMPORT_PUTTY); + src_texts[1] = sd_l10n_get(SD_MSG_UI_IMPORT_OPENSSH); + run_texts[0] = sd_l10n_get(SD_MSG_UI_IMPORT_RUN); + run_texts[1] = sd_l10n_get(SD_MSG_UI_IMPORT_CLOSE); + e = ui_dlg_init(d, sd_l10n_get(SD_MSG_UI_IMPORT_TITLE), IMP_WIDTH, face, points, ui_measure_cb, &m); + if (e == SD_OK) e = ui_dlg_text(d, 0, sd_l10n_get(SD_MSG_UI_IMPORT_INTRO), 0); + if (e == SD_OK) e = ui_dlg_buttons(d, src_ids, src_texts, 2, 0); + /* eleven line breaks: the placeholder is as tall as the list view that replaces it (12 lines) */ + if (e == SD_OK) e = ui_dlg_multiline(d, IMP_LIST, L"\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n", 12); + if (e == SD_OK) e = ui_dlg_buttons(d, run_ids, run_texts, 2, IMP_RUN); + if (e == SD_OK) e = ui_dlg_build(d, &blob, &len); + ui_measure_free(&m); + sd_free(d); + if (e == SD_OK && blob != NULL) + DialogBoxIndirectParamW(g_app.inst, (LPCDLGTEMPLATEW)blob, owner, import_proc, (LPARAM)&c); + sd_free(blob); + sd_import_result_free(&c.res); + ui_main_refresh(); +} diff --git a/apps/shelldrive-ui/ui_int.h b/apps/shelldrive-ui/ui_int.h new file mode 100644 index 0000000..b0a4c71 --- /dev/null +++ b/apps/shelldrive-ui/ui_int.h @@ -0,0 +1,300 @@ +/* shelldrive-ui internals (03 §1.4, §7): the application state and the functions shared by main.c, + * ui_ipc.c, ui_model.c, ui_tray.c, ui_main.c and ui_icon.c. Only the UI thread touches g_app; the IPC + * thread talks to the UI thread through PostMessage (the messages below) and never reads g_app. + * + * The profile editor and the mount dialog (ui_editor.c), the profile list (ui_profiles.c), the import wizard + * (ui_import.c), the .sdmount verb, the secondary windows of M3 (ui_win.h: host keys, Logs, Settings, About) and + * the prompt host (prompts.ui, ui_prompt.c), the first-run wizard (ui_wizard.c), the balloon rate limit and queue + * (ui_tray.c, ui_notify_model.c) and dark title bars (ui_dark.c) are in. Not yet: reconnect. */ +#ifndef SD_UI_INT_H +#define SD_UI_INT_H + +#include + +#include "sd/base.h" +#include "sd_base/sd_err.h" +#include "sd_base/sd_json.h" +#include "sd_config/sd_config.h" +#include "sd_l10n/sd_l10n_catalog.h" +#include "ui_events_model.h" +#include "ui_notice_model.h" +#include "ui_wire.h" + +/* Messages posted to the control window (WM_APP range). */ +#define UI_WM_IPC_STATE (WM_APP + 1) /* wParam: 1 agent connected, 0 disconnected */ +#define UI_WM_IPC_LIST (WM_APP + 2) /* lParam: sd_json* result of "list" (owned by the UI) */ +#define UI_WM_IPC_EVENT (WM_APP + 3) /* lParam: sd_json* event envelope (owned by the UI) */ +#define UI_WM_IPC_REPLY (WM_APP + 4) /* lParam: ui_ipc_reply* (owned by the UI) */ +#define UI_WM_TRAY (WM_APP + 5) /* tray icon callback: lParam is the mouse message */ +#define UI_WM_IPC_PROMPTS (WM_APP + 6) /* lParam: sd_json* result of "prompt.list" (owned by the UI) */ +#define UI_WM_IPC_HISTORY (WM_APP + 8) /* lParam: sd_json* result of "events.recent" (owned by the UI) */ +#define UI_WM_SVC_ATTENTION (WM_APP + 7) /* wParam: count of prompts waiting for an administrator (ui_svc.c) */ + +/* Timer of the prompt host (--prompt-host): once a second, the idle exit of 60 s is counted. */ +#define UI_TIMER_PROMPT_IDLE 1 +#define UI_PROMPT_IDLE_MS 60000u +/* Timer of the queued balloons (7.7): once a second while balloons wait; ui_tray_flush shows the next one. */ +#define UI_TIMER_NOTIFY 2 + +/* Command ids of the tray menu and the main window controls. */ +#define UI_CMD_OPEN 1001 +#define UI_CMD_EXIT 1002 +#define UI_CMD_UNMOUNT_BASE 2000 /* + index of the mount in the model */ +#define UI_CMD_MOUNT 3001 +#define UI_CMD_UNMOUNT 3002 +#define UI_CMD_REFRESH 3003 +#define UI_CMD_NEW 3004 +#define UI_CMD_EDIT 3005 +#define UI_CMD_DELETE 3006 +#define UI_CMD_DUPLICATE 3007 +#define UI_CMD_OPEN_DRIVE 3008 +#define UI_CMD_QUICK_MOUNT 3009 +#define UI_CMD_IMPORT 3010 +#define UI_CMD_ADMIN 1003 /* tray: relaunch elevated to answer the service prompts (ui_svc.c) */ +#define UI_CMD_SECURITY 3011 /* Connection menu: the legacy and Terrapin details of the selected drive (ui_secdlg.c) */ +#define UI_CTL_TOOLBAR 4003 +#define UI_CTL_LIST 4001 +#define UI_CTL_STATUS 4002 + +#define UI_MAX_MOUNTS 64 + +typedef enum ui_ipc_kind { + UI_IPC_LIST, UI_IPC_UNMOUNT, UI_IPC_MOUNT, UI_IPC_ANSWER, + UI_IPC_RELOAD, /* config.reload (ui_ipc_config_reload, ui_settings.c) */ + UI_IPC_ANNOUNCED, /* hostkey.announced.list (ui_hostkeys.c) */ + UI_IPC_ACCEPT, /* hostkey.announced.accept (ui_hostkeys.c) */ + UI_IPC_LOG, /* log.tail (ui_logs.c) */ + UI_IPC_UPDATE_CHECK, /* update.check (ui_update.c) */ + UI_IPC_UPDATE_APPLY, /* update.apply, consent true (ui_update.c) */ + UI_IPC_HK_ADD, /* hostkey.add (ui_hostkeys.c): the agent connects to the server, which can take a while */ + UI_IPC_HK_REJECT, /* hostkey.announced.reject (ui_hostkeys.c): a declined offer leaves the announcements */ + UI_IPC_AGENT_START /* the result of ui_ipc_start_agent (status bar): err is SD_OK or why the agent did not start */ +} ui_ipc_kind; + +typedef struct ui_ipc_reply { + ui_ipc_kind kind; + sd_err err; /* SD_OK on success */ + wchar_t drive[3]; /* UI_IPC_UNMOUNT: the drive asked for */ + sd_json *result; /* may be NULL */ +} ui_ipc_reply; + +/* One mount as the agent reports it in "list" (03 §2.4). Identifiers are ASCII, text is UTF-16. */ +typedef struct ui_mount { + char id[40]; /* mount_id */ + char profile_id[33]; /* profile_id of the list item; "" for an ad-hoc mount */ + char state[24]; /* state token: connecting, mounted, ... */ + wchar_t state_w[24]; /* the same token in UTF-16, shown when the token is unknown */ + wchar_t drive[3]; /* "X:" */ + wchar_t profile[128]; + wchar_t backend[16]; + wchar_t server[280]; /* host, with ":port" when not 22 */ + char host[128]; /* host as the agent reports it, printable ASCII (dialogs of the main window) */ + char session[24]; /* connection.session ("" when the agent sent none), for the Reconnecting status */ + int attempt; /* connection.attempt */ + unsigned sec; /* UI_MV_SEC_* bits: legacy_algorithms and terrapin_exposed warnings (7.6) */ + char algos[256]; /* comma-joined outdated algorithm names (list legacy_algorithms, or the last warning event) */ + unsigned char algo_cat[16]; /* ui_mv_cat of the name at the same position in algos; 0 (other) when not reported */ + int scope; /* SD_PROFILE_SCOPE_* from the list's scope token; -1 when the agent sent none */ + int sec_from_list; /* 1 when sec came from the mount's own legacy/terrapin_exposed fields (current connection) */ +} ui_mount; + +typedef struct ui_model { + ui_mount items[UI_MAX_MOUNTS]; + size_t count; +} ui_model; + +typedef struct ui_app { + HINSTANCE inst; + HWND ctrl; /* message-only window: IPC, tray callbacks, WM_COPYDATA */ + HWND main; /* main window, created hidden or shown */ + HWND list, status, toolbar; /* toolbar: text buttons New, Edit, Delete, Mount, Unmount, Open, Host keys, Logs (7.5) */ + HFONT font; + unsigned dpi; /* DPI of the main window (ui_dpi_window) */ + UINT wm_taskbar; /* "TaskbarCreated": the tray icon is added again */ + wchar_t class_ctrl[320]; /* com.librestatic.shelldrive.ui. */ + wchar_t class_main[320]; /* com.librestatic.shelldrive.ui..main */ + HANDLE mutex; /* SD_IPC_OBJ_UI_MUTEX, held for the life of the first instance */ + int agent_up; + int agent_features; /* UI_FEAT_* of the connected agent (hello), 0 when none */ + sd_settings settings; /* settings.ini as last loaded or saved: [ui] notify and the notify_ switches (7.7) */ + int quit; + int prompt_host; /* --prompt-host: prompt dialogs and tray only, no main window, never starts the agent */ + unsigned idle_ms; /* prompt host: time without an open prompt (UI_PROMPT_IDLE_MS exits) */ + int elevated_admin; /* --elevated-admin: service prompts only (ui_svc.c), no agent, no tray, prompt_host set too */ + int elevated_service; /* --elevated-service: only the Service page (ui_service.c), no agent, no tray, no main window */ + int user_admin; /* the logged-on user is an administrator (linked token on Vista+), see ui_svc_user_is_admin */ + int attention; /* prompts waiting for an administrator on the service (0 unless user_admin) */ + ui_model model; +} ui_app; + +extern ui_app g_app; + +/* ---- main.c ---- */ +void ui_app_handle_request(const ui_request *req); +void ui_app_on_attention(int count); +void ui_app_on_agent_state(int up); +void ui_app_on_list(sd_json *result); +void ui_app_on_event(sd_json *event); +void ui_app_on_reply(ui_ipc_reply *reply); +void ui_app_quit(void); +void ui_app_on_prompt_list(sd_json *result); +/* The result of events.recent (the agent's event history): recorded in the event list; takes ownership. */ +void ui_app_on_history(sd_json *result); +void ui_app_on_timer(UINT_PTR id); + +/* ---- ui_model.c: the mount list and text helpers ---- */ +void ui_text_utf8(const char *s, wchar_t *out, size_t cch); +void ui_text_ascii(const wchar_t *s, char *out, size_t cch); +void ui_model_clear(ui_model *m); +void ui_model_set_list(ui_model *m, const sd_json *result); +ui_mount *ui_model_find_id(ui_model *m, const char *id); +ui_mount *ui_model_find_drive(ui_model *m, const wchar_t *drive); +void ui_model_remove(ui_model *m, ui_mount *item); +/* connection.session and connection.attempt of a list item or a mount.state event. */ +void ui_model_set_connection(ui_mount *it, const sd_json *data); +const wchar_t *ui_model_state_text(const ui_mount *item); +/* Formats a catalog message with one string argument and one optional integer (count). */ +wchar_t *ui_format1(sd_msg_id msg_id, const char *name, const wchar_t *value); +wchar_t *ui_format_count(sd_msg_id msg_id, long long count); + +/* ---- ui_ipc.c: the agent client thread ---- */ +sd_err ui_ipc_start(void); +void ui_ipc_stop(void); +sd_err ui_ipc_list(void); +/* "Agent not running - Start" (03 §7.5): connects now and starts the agent when it is not running + (sd_ipc_connect_agent_or_start). The outcome of a failed start is a UI_IPC_AGENT_START reply. */ +sd_err ui_ipc_start_agent(void); +sd_err ui_ipc_unmount(const wchar_t *drive); +sd_err ui_ipc_mount(const char *profile_id); +/* mount with the complete params object of 03 §2.4 (ui_mount_params_json). */ +sd_err ui_ipc_mount_params(const char *json); +/* config.reload: the agent re-reads the profiles after a save or delete (03 §2.4, decision 3). */ +sd_err ui_ipc_config_reload(void); +/* prompt.answer, see ui_ipc.c: the JSON is copied; the caller wipes its own copy. */ +sd_err ui_ipc_answer(const char *json, size_t len); +/* Generic call for the secondary windows: the reply comes back as UI_WM_IPC_REPLY with this kind. params may be + NULL. */ +sd_err ui_ipc_call(ui_ipc_kind kind, const char *method, const char *params); + +/* ---- ui_svc.c: the service pipe client (03 §2.6 rule 3): the attention watch of administrators and the + elevated admin mode (--elevated-admin). Neither starts the service. ---- */ +/* admin 0: the attention watch (subscribes to attention only). admin 1: prompts and answers (prompts.ui). */ +sd_err ui_svc_start(int admin); +void ui_svc_stop(void); +/* prompt.answer on the service pipe (admin mode). The JSON is copied; the caller wipes its own copy. */ +sd_err ui_svc_answer(const char *json, size_t len); +/* 1 when the logged-on user is a member of BUILTIN\Administrators (the linked, elevated token on Vista and later). */ +int ui_svc_user_is_admin(void); +/* Relaunches this executable with --elevated-admin (UAC on Vista and later). Returns 1 when one is running or + was started; the window owner gets a message only when the launch itself fails. */ +int ui_svc_launch_admin(HWND owner); +/* 1 when this process has the full administrator token (TokenElevation on Vista and later; the administrator check on XP). */ +int ui_svc_process_elevated(void); +/* Relaunches this executable with --elevated-service: the Service page alone, in an elevated process. Same return rules. */ +int ui_svc_launch_service_page(HWND owner); + +/* ---- ui_tray.c: notification area icon ---- */ +sd_err ui_tray_add(void); +void ui_tray_remove(void); +void ui_tray_readd(void); +void ui_tray_update(void); +void ui_tray_callback(LPARAM mouse_message); +void ui_tray_balloon(const char *code, const wchar_t *drive); +/* 1 when a balloon of this sd_notify_kind may be shown (the notify master and the kind's switch; -1: master only). */ +int ui_tray_notify_wanted(int kind); +/* Balloon with one catalog message; arg fills the placeholder called name ("drive" or "profile"). */ +void ui_tray_notify_msg(sd_msg_id msg, const char *name, const wchar_t *arg); +/* Balloon with a text the caller formatted; msg names the kind (the balloon click and the rate limit use it). */ +void ui_tray_notify_text(sd_msg_id msg, const wchar_t *subject, const wchar_t *text); +/* hostkeys.announced: the balloon "example.org offered new host keys. Review" when notifications are on. */ +void ui_tray_on_hostkeys_announced(const sd_json *data); +/* The notices of 7.7 that follow the agent's state (ui_notice_model.h): recorded in the event list always, shown as a balloon + when [ui] notify and the kind's switch allow it. detail: the proxy (host:port) or the host; count: prompts waiting. */ +void ui_tray_notice(ui_nt_kind kind, const wchar_t *subject, const wchar_t *detail, int count); +/* The same notices for the agent's event history (events.recent): recorded in the event list at `when` (unix seconds), + never shown as a balloon. */ +void ui_tray_history_notice(ui_nt_kind kind, long long when, const wchar_t *subject, const wchar_t *detail); +void ui_tray_history_warning(const char *code, long long when, const wchar_t *subject); +void ui_tray_history_hostkeys(long long when, const wchar_t *host); +/* Why a drive reconnected (mount.state reason): the localized text of a known error code, the code itself otherwise. */ +void ui_reason_text(const char *code, wchar_t *out, size_t cch); +/* Shows the next queued balloon that the rate limit allows (UI_TIMER_NOTIFY, once a second). */ +void ui_tray_flush(void); + +/* ---- ui_prompt.c: prompt dialogs (03 §2.5, §5.2, §7.6) ---- */ +void ui_prompt_on_request(const sd_json *data); +void ui_prompt_on_closed(const char *prompt_id, const char *reason); +void ui_prompt_on_list(const sd_json *result); +void ui_prompt_close_all(void); +size_t ui_prompt_count(void); +int ui_prompt_filter(MSG *msg); +int ui_prompt_smoke_test(void); + +/* ---- ui_profiles.c: the profile list and the .sdmount file ---- */ +sd_err ui_profiles_load(void); +void ui_profiles_clear(void); +size_t ui_profiles_count(void); +const sd_profile *ui_profiles_at(size_t i); +const sd_profile *ui_profiles_find_id(const char *id); +sd_err ui_sdmount_read(const wchar_t *path, unsigned char *buf, size_t cap, size_t *len); +int ui_file_has_mark(const wchar_t *path); + +/* ---- ui_editor.c: the profile editor, the mount dialog and the confirmation dialog (03 §7.5) ---- */ +typedef enum ui_edit_mode { UI_EDIT_NEW = 0, UI_EDIT_EDIT, UI_EDIT_ADHOC } ui_edit_mode; +/* Runs the editor (modal, owner may be NULL). p is the profile in and out. Returns 1 when the user chose OK + and every field is valid; the caller saves. */ +int ui_editor_run(HWND owner, ui_edit_mode mode, sd_profile *p); +/* Yes/No question; default_yes puts the default button on Yes. yes_id/no_id are catalog ids. */ +int ui_confirm(HWND owner, sd_msg_id title, sd_msg_id text_id, const wchar_t *arg, sd_msg_id yes_id, + sd_msg_id no_id, int default_yes); +/* The import dialog (PuTTY sessions or the OpenSSH config) with its preview; owner may be NULL. */ +void ui_import_run(HWND owner); +/* Creates every page of every editor mode hidden and destroys it (--test-editor). */ +int ui_editor_smoke_test(void); + +/* ---- ui_main.c: the main window ---- */ +int ui_main_register_class(HINSTANCE inst, const wchar_t *class_name); +HWND ui_main_create(HINSTANCE inst, const wchar_t *class_name, int visible); +void ui_main_show(void); +void ui_main_refresh(void); +int ui_main_test_controls(void); +/* The selected profile of the list (NULL when none), and its mount (NULL when not mounted). */ +const sd_profile *ui_main_selected_profile(void); +ui_mount *ui_main_selected_mount(void); +void ui_main_reload_profiles(void); +/* The status bar's "Agent not running - Start": asks the IPC thread to start and connect (once at a time). */ +void ui_main_start_agent(void); +/* The outcome of that request (UI_IPC_AGENT_START): a failed start says so; a started agent is the state going up. */ +void ui_main_agent_start_done(sd_err err); +/* Shows a drive in Explorer (ShellExecuteW "explore"); drive is "X:". */ +void ui_open_drive(const wchar_t *drive); + +/* ---- ui_hkissue.c: changed host key and wrong key type (03 §5.2, §5.4, TM-R04) ---- */ +/* A mount.state event with an error: when it is host_key_mismatch or host_key_type_unavailable the balloon is raised (it + cannot be disabled, 7.7) and the details are kept for the click. The drive is the mount's drive letter ("" if none). */ +void ui_hkissue_on_mount_state(const sd_json *data, const char *host, const wchar_t *drive); +/* The click on the balloon of the last issue: the details dialog of a changed key, the host key manager for a key type. */ +void ui_hkissue_open_last(void); +/* --test-windows: the dialog template builds and parses for every source. */ +int ui_hkissue_smoke(void); + +/* ---- ui_secdlg.c: the legacy and Terrapin details dialog (03 §7.6) ---- */ +/* Shows the details of the mount on drive ("X:"); modal over owner. A drive without a security warning shows nothing. */ +void ui_secdlg_open(HWND owner, const wchar_t *drive); +/* The mount of a pending "Refuse ... and reconnect" has been unmounted: mounts the profile again. */ +void ui_secdlg_on_unmounted(const char *profile_id); +int ui_secdlg_smoke(void); + +/* ---- ui_icon.c: programmatically drawn icons (no third-party art) ---- */ +typedef enum ui_icon_kind { UI_ICON_IDLE = 0, UI_ICON_CONNECTED, UI_ICON_WARNING, UI_ICON_COUNT } ui_icon_kind; +HICON ui_icon_get(ui_icon_kind kind, int size); +void ui_icon_free_all(void); + +/* ---- ui_events.c: the event list (03 §7.7) ---- */ +/* Records one event (a notification, shown or not) in the list of the Events window; the time is now. */ +void ui_events_record(ui_ev_kind kind, const wchar_t *subject, const wchar_t *text); +/* As ui_events_record, for something that happened at unix_time (seconds, UTC): the agent's event history. */ +void ui_events_record_at(ui_ev_kind kind, long long unix_time, const wchar_t *subject, const wchar_t *text); +size_t ui_events_count(void); + +#endif /* SD_UI_INT_H */ diff --git a/apps/shelldrive-ui/ui_ipc.c b/apps/shelldrive-ui/ui_ipc.c new file mode 100644 index 0000000..6a6ad92 --- /dev/null +++ b/apps/shelldrive-ui/ui_ipc.c @@ -0,0 +1,458 @@ +/* shelldrive-ui: the agent client thread (03 §2, §1.4). + * + * One thread owns the sd_ipc_conn: it connects (starting the agent only at the first attempt of this + * process, so a user who stopped the agent is not fought), says hello, subscribes to mount, warning and + * prompt, asks for the mount list, and then waits on three handles: the stop event, the request queue + * (UI thread -> agent calls) and the connection's event handle (agent -> UI events). Results travel back + * to the UI thread as posted messages that own their sd_json payload. + * + * The UI declares prompts.ui in hello and subscribes to "prompt": the agent routes prompt.request here, and + * the answers (prompt.answer) leave through this thread (ui_ipc_answer). A prompt-host process (--prompt-host) + * never starts an agent: its connect passes start_if_missing = 0 and it leaves when the agent is gone. */ +#include +#include +#include + +#include "sd/version.h" +#include "sd_base/sd_err.h" +#include "sd_base/sd_mem.h" +#include "sd_ipc/sd_ipc.h" +#include "sd_l10n/sd_l10n.h" +#include "sd_platform/sd_log.h" +#include "ui_admin_model.h" +#include "ui_int.h" +#include "ui_win.h" + +#define UI_IPC_QUEUE 8u +#define UI_IPC_CALL_MS 10000u +#define UI_IPC_RETRY_MS 10000u +#define UI_IPC_CONNECT_MS 10000u +#define UI_IPC_UPDATE_MS 120000u /* update.check: the manifest download may take a while */ +#define UI_IPC_APPLY_MS 630000u /* update.apply: the agent's staged setup waits up to 600 s (04 §9) */ +#define UI_IPC_HKADD_MS 60000u /* hostkey.add: the agent connects to the server and reads its host keys */ + +typedef struct ui_ipc_req { + ui_ipc_kind kind; + char method[32]; /* the longest is hostkey.announced.accept (24 characters) */ + char *params; /* heap copy, NULL when the call has no parameters */ + wchar_t drive[3]; +} ui_ipc_req; + +static struct { + HANDLE thread; + HANDLE stop; + HANDLE wake; + HANDLE start; /* "Agent not running - Start" (ui_ipc_start_agent): connect now and start the agent */ + CRITICAL_SECTION cs; + int cs_ready; + ui_ipc_req queue[UI_IPC_QUEUE]; + size_t count; +} g_ipc; + +/* Agent features of the hello reply that the windows use (ui_win.h). */ +static int features_of(sd_ipc_conn *c) +{ + int f = 0; + if (sd_ipc_conn_has_feature(c, "log.tail")) + f |= UI_FEAT_LOG_TAIL; + if (sd_ipc_conn_has_feature(c, "config.reload")) + f |= UI_FEAT_RELOAD; + if (sd_ipc_conn_has_feature(c, "update.check")) + f |= UI_FEAT_UPDATE; + if (sd_ipc_conn_has_feature(c, "update.apply")) + f |= UI_FEAT_UPDATE_APPLY; + return f; +} + +static void post_state(int up) +{ + PostMessageW(g_app.ctrl, UI_WM_IPC_STATE, (WPARAM)(up ? 1 : 0), 0); +} + +static void post_json(UINT msg, sd_json *j) +{ + if (j == NULL) + return; + if (!PostMessageW(g_app.ctrl, msg, 0, (LPARAM)j)) + sd_json_free(j); +} + +static void post_reply(ui_ipc_kind kind, sd_err err, const wchar_t *drive, sd_json *result) +{ + ui_ipc_reply *r = sd_alloc(sizeof(*r)); + if (r == NULL) { + sd_json_free(result); + return; + } + memset(r, 0, sizeof(*r)); + r->kind = kind; + r->err = err; + if (drive) + wcscpy_s(r->drive, SD_COUNTOF(r->drive), drive); + r->result = result; + if (!PostMessageW(g_app.ctrl, UI_WM_IPC_REPLY, 0, (LPARAM)r)) { + sd_json_free(result); + sd_free(r); + } +} + +static int pop_request(ui_ipc_req *out) +{ + int got = 0; + EnterCriticalSection(&g_ipc.cs); + if (g_ipc.count > 0) { + *out = g_ipc.queue[0]; + memmove(&g_ipc.queue[0], &g_ipc.queue[1], (g_ipc.count - 1) * sizeof(ui_ipc_req)); + g_ipc.count--; + memset(&g_ipc.queue[g_ipc.count], 0, sizeof(ui_ipc_req)); + got = 1; + } + LeaveCriticalSection(&g_ipc.cs); + return got; +} + +/* Frees the parameters of a request. An answer carries a secret: its copy is wiped before it is freed. */ +static void free_params(ui_ipc_req *r) +{ + if (r->params != NULL && r->kind == UI_IPC_ANSWER) + SecureZeroMemory(r->params, strlen(r->params)); + sd_free(r->params); + r->params = NULL; +} + +static void fail_queued(void) +{ + ui_ipc_req r; + while (pop_request(&r)) { + post_reply(r.kind, SD_E_DISCONNECTED, r.drive, NULL); + free_params(&r); + } +} + +static sd_err subscribe_topics(sd_ipc_conn *c) +{ + /* The agent sends every warning on both "mount" and "warning" (agent.c); subscribing to both would show + each balloon twice, so warnings arrive on "mount" only. TODO: subscribe to "warning" when the agent + drops the mount copy (proto 2, docs/design/m3-contracts-draft.md). */ + static const char k_params[] = "{\"topics\":[\"mount\",\"prompt\"]}"; + sd_json *res = NULL; + sd_ipc_error err; + sd_err e = sd_ipc_call_raw(c, "subscribe", k_params, sizeof(k_params) - 1, UI_IPC_CALL_MS, &res, &err); + sd_json_free(res); + return e; +} + +/* Time allowed for one call: the updater calls are deferred and may take much longer than the others. */ +static unsigned ui_ipc_call_ms(ui_ipc_kind kind) +{ + if (kind == UI_IPC_UPDATE_CHECK) + return UI_IPC_UPDATE_MS; + if (kind == UI_IPC_HK_ADD) + return UI_IPC_HKADD_MS; + if (kind == UI_IPC_UPDATE_APPLY) + return UI_IPC_APPLY_MS; + return UI_IPC_CALL_MS; +} + +static int pump_requests(sd_ipc_conn *c) +{ + ui_ipc_req r; + while (pop_request(&r)) { + sd_json *res = NULL; + sd_ipc_error err; + sd_err e; + + memset(&err, 0, sizeof(err)); + e = sd_ipc_call_raw(c, r.method, r.params, r.params ? strlen(r.params) : 0, ui_ipc_call_ms(r.kind), &res, &err); + if (r.kind == UI_IPC_LIST) { + if (e == SD_OK) + post_json(UI_WM_IPC_LIST, res); + else + sd_json_free(res); + } else { + post_reply(r.kind, e, r.drive, res); + } + free_params(&r); + if (e == SD_E_DISCONNECTED) + return 0; + } + return 1; +} + +/* Returns 0 when the connection is lost, 1 when no event is pending any more. */ +static int pump_events(sd_ipc_conn *c) +{ + for (;;) { + sd_json *ev = NULL; + sd_err e = sd_ipc_next_event(c, 0, &ev); + if (e == SD_OK && ev != NULL) { + post_json(UI_WM_IPC_EVENT, ev); + continue; + } + if (e == SD_E_TIMEOUT || e == SD_OK) + return 1; + return 0; + } +} + +/* Serves one connection until it is lost (returns 0) or the stop event is set (returns 1). */ +static int serve(sd_ipc_conn *c) +{ + HANDLE h[3]; + h[0] = g_ipc.stop; + h[1] = g_ipc.wake; + h[2] = sd_ipc_event_handle(c); + for (;;) { + DWORD w = WaitForMultipleObjects(3, h, FALSE, INFINITE); + if (w == WAIT_OBJECT_0) + return 1; + if (w == WAIT_OBJECT_0 + 1) { + if (!pump_requests(c)) + return 0; + } else if (w == WAIT_OBJECT_0 + 2) { + if (!pump_events(c)) + return 0; + } else { + return 0; + } + } +} + +static DWORD WINAPI ipc_thread(void *arg) +{ + int first = 1; + int failures = 0; + int want_start = 0; + (void)arg; + for (;;) { + sd_ipc_conn *c = NULL; + sd_ipc_hello hello; + sd_err e; + int stop = 0; + + memset(&hello, 0, sizeof(hello)); + hello.client_kind = "ui"; + hello.client_version = SD_VERSION_STRING; + hello.lang = sd_l10n_lang_code(sd_l10n_lang()); + hello.caps = SD_IPC_CAP_PROMPTS_UI; + + /* PROMPT-HOST RULE (03 §1.4): a --prompt-host process never starts the agent. The agent starts us, not + the other way round. */ + if (want_start && !g_app.prompt_host) { + /* the user asked for it in the status bar: the same start and connect as the CLI (03 §7.5) */ + e = sd_ipc_connect_agent_or_start(&hello, &c); + post_reply(UI_IPC_AGENT_START, e, NULL, NULL); + } else { + e = sd_ipc_connect(SD_IPC_AGENT, &hello, first && !g_app.prompt_host, UI_IPC_CONNECT_MS, &c); + } + want_start = 0; + first = 0; + if (e == SD_OK) { + sd_json *res = NULL; + sd_ipc_error err; + failures = 0; + memset(&err, 0, sizeof(err)); + ResetEvent(g_ipc.start); /* a click that came while we were connecting is answered by the connection */ + InterlockedExchange((volatile LONG *)&g_app.agent_features, features_of(c)); + post_state(1); + if (subscribe_topics(c) == SD_OK && + sd_ipc_call_raw(c, "list", NULL, 0, UI_IPC_CALL_MS, &res, &err) == SD_OK) { + post_json(UI_WM_IPC_LIST, res); + } else { + sd_json_free(res); + } + /* the open prompts of the agent: dialogs for prompts it no longer holds are closed (ui_prompt.c) */ + if (sd_ipc_conn_has_feature(c, "prompt.list")) { + res = NULL; + memset(&err, 0, sizeof(err)); + if (sd_ipc_call_raw(c, "prompt.list", NULL, 0, UI_IPC_CALL_MS, &res, &err) == SD_OK) + post_json(UI_WM_IPC_PROMPTS, res); + else + sd_json_free(res); + } + /* the agent's event history: a UI that starts late shows what happened before (03 2.4, 7.7) */ + if (sd_ipc_conn_has_feature(c, "events.recent")) { + res = NULL; + memset(&err, 0, sizeof(err)); + if (sd_ipc_call_raw(c, "events.recent", NULL, 0, UI_IPC_CALL_MS, &res, &err) == SD_OK) + post_json(UI_WM_IPC_HISTORY, res); + else + sd_json_free(res); + } + stop = serve(c) == 1; + sd_ipc_close(c); + InterlockedExchange((volatile LONG *)&g_app.agent_features, 0); + post_state(0); + fail_queued(); + } else { + SD_LOGD("ui", "agent not reachable (%s)", sd_err_name(e)); + fail_queued(); + /* a prompt-host process whose agent does not answer twice has no work left: it leaves */ + if (g_app.prompt_host && ++failures >= 2) { + post_state(0); + break; + } + } + if (stop) + break; + { + HANDLE w[2]; + DWORD r; + w[0] = g_ipc.stop; + w[1] = g_ipc.start; + r = WaitForMultipleObjects(2, w, FALSE, UI_IPC_RETRY_MS); + if (r == WAIT_OBJECT_0) + break; + if (r == WAIT_OBJECT_0 + 1) + want_start = 1; /* a request that arrived while connected was dropped at the connect below */ + } + } + fail_queued(); + return 0; +} + +static sd_err enqueue(ui_ipc_kind kind, const char *method, const char *params, const wchar_t *drive) +{ + ui_ipc_req r; + sd_err e = SD_OK; + + if (g_ipc.thread == NULL) + return SD_E_SHUTDOWN; + memset(&r, 0, sizeof(r)); + r.kind = kind; + if (strlen(method) >= sizeof(r.method)) + return SD_E_INVALID_PARAM; + strcpy_s(r.method, sizeof(r.method), method); + if (params != NULL) { + size_t n = strlen(params); + r.params = sd_alloc(n + 1); + if (r.params == NULL) + return SD_E_NO_MEMORY; + memcpy(r.params, params, n + 1); + } + if (drive != NULL) + wcscpy_s(r.drive, SD_COUNTOF(r.drive), drive); + + EnterCriticalSection(&g_ipc.cs); + if (g_ipc.count >= UI_IPC_QUEUE) { + e = SD_E_BUSY; + } else { + g_ipc.queue[g_ipc.count++] = r; + } + LeaveCriticalSection(&g_ipc.cs); + if (e != SD_OK) { + free_params(&r); + return e; + } + SetEvent(g_ipc.wake); + return SD_OK; +} + +sd_err ui_ipc_start(void) +{ + memset(&g_ipc, 0, sizeof(g_ipc)); + InitializeCriticalSection(&g_ipc.cs); + g_ipc.cs_ready = 1; + g_ipc.stop = CreateEventW(NULL, TRUE, FALSE, NULL); + g_ipc.wake = CreateEventW(NULL, FALSE, FALSE, NULL); + g_ipc.start = CreateEventW(NULL, FALSE, FALSE, NULL); + if (g_ipc.stop == NULL || g_ipc.wake == NULL || g_ipc.start == NULL) + return SD_E_NO_MEMORY; + g_ipc.thread = CreateThread(NULL, 0, ipc_thread, NULL, 0, NULL); + if (g_ipc.thread == NULL) + return SD_E_FAILURE; + return SD_OK; +} + +void ui_ipc_stop(void) +{ + if (g_ipc.thread != NULL) { + SetEvent(g_ipc.stop); + WaitForSingleObject(g_ipc.thread, 15000); + CloseHandle(g_ipc.thread); + g_ipc.thread = NULL; + } + if (g_ipc.stop) + CloseHandle(g_ipc.stop); + if (g_ipc.wake) + CloseHandle(g_ipc.wake); + if (g_ipc.start) + CloseHandle(g_ipc.start); + if (g_ipc.cs_ready) + DeleteCriticalSection(&g_ipc.cs); + memset(&g_ipc, 0, sizeof(g_ipc)); +} + +sd_err ui_ipc_list(void) +{ + return enqueue(UI_IPC_LIST, "list", NULL, NULL); +} + +sd_err ui_ipc_unmount(const wchar_t *drive) +{ + char params[32]; + char d[4]; + ui_text_ascii(drive, d, sizeof(d)); + if (d[0] == 0 || d[1] != ':' || d[2] != 0) + return SD_E_INVALID_PARAM; + sprintf_s(params, sizeof(params), "{\"target\":\"%s\"}", d); + return enqueue(UI_IPC_UNMOUNT, "unmount", params, drive); +} + +sd_err ui_ipc_mount(const char *profile_id) +{ + char params[64]; + if (profile_id == NULL || strlen(profile_id) != 32) + return SD_E_INVALID_PARAM; + sprintf_s(params, sizeof(params), "{\"profile\":\"%s\"}", profile_id); + return enqueue(UI_IPC_MOUNT, "mount", params, NULL); +} + +/* mount with a complete params object (ui_mount_params_json): the profile or the ad-hoc connection, with the + overrides of the mount dialog. The queue keeps its own copy. */ +sd_err ui_ipc_mount_params(const char *json) +{ + if (json == NULL || json[0] != '{') + return SD_E_INVALID_PARAM; + return enqueue(UI_IPC_MOUNT, "mount", json, NULL); +} + +/* config.reload: the agent re-reads the profile store (03 §2.4). A failed call is only logged. */ +sd_err ui_ipc_config_reload(void) +{ + return enqueue(UI_IPC_RELOAD, "config.reload", NULL, NULL); +} + +/* prompt.answer: json is the complete request object, NUL-terminated, with len bytes. The queue keeps its own + copy, which is wiped after the call (free_params). The caller wipes its copy. In the elevated admin mode the + answer goes to the service pipe instead (ui_admin_answer_route, ui_svc.c). */ +sd_err ui_ipc_answer(const char *json, size_t len) +{ + if (json == NULL || strlen(json) != len) + return SD_E_INVALID_PARAM; + if (ui_admin_answer_route(g_app.elevated_admin) == UI_ROUTE_SERVICE) + return ui_svc_answer(json, len); + return enqueue(UI_IPC_ANSWER, "prompt.answer", json, NULL); +} + +/* Generic call of the secondary windows (ui_win.c): the reply is UI_WM_IPC_REPLY with this kind, and the + result is owned by the UI until the reply is handled. */ +sd_err ui_ipc_call(ui_ipc_kind kind, const char *method, const char *params) +{ + if (method == NULL) + return SD_E_INVALID_PARAM; + return enqueue(kind, method, params, NULL); +} + +/* The status bar's "Agent not running - Start" (03 §7.5): the thread connects at once and starts the agent when it is + not running. The result comes back as UI_IPC_AGENT_START (error only; success is the agent state going up). A prompt + host never starts an agent (1.4). Returns SD_E_SHUTDOWN when the thread is not running. */ +sd_err ui_ipc_start_agent(void) +{ + if (g_ipc.thread == NULL || g_ipc.start == NULL) + return SD_E_SHUTDOWN; + if (g_app.prompt_host) + return SD_E_ACCESS_DENIED; + SetEvent(g_ipc.start); + return SD_OK; +} diff --git a/apps/shelldrive-ui/ui_logs.c b/apps/shelldrive-ui/ui_logs.c new file mode 100644 index 0000000..37064ec --- /dev/null +++ b/apps/shelldrive-ui/ui_logs.c @@ -0,0 +1,434 @@ +/* shelldrive-ui: the Logs viewer (03 §10.1, §2.4.1, §7.5). + * + * The window tails the agent's log ring with log.tail (poll every second while it is visible, one call in + * flight at a time). The first call returns the last 2000 records; each later call passes the cursor of the + * previous reply, so only new records travel. The model (ui_logs_model.c) keeps the records and applies the + * cursor rules: a reset clears the list, dropped records are reported in the status line. The level filter and + * the search text are applied locally to the kept records. Copy selection copies the selected lines (or all of + * them when nothing is selected); Open log folder opens the per-user log folder (3.2). */ +#include +#include +#include +#include +#include +#include + +#include "sd_base/sd_err.h" +#include "sd_base/sd_mem.h" +#include "sd_config/sd_config.h" +#include "sd_l10n/sd_l10n.h" +#include "sd_l10n_ui_ids.h" +#include "ui_dpi.h" +#include "ui_int.h" +#include "ui_logs_model.h" +#include "ui_win.h" + +#define LG_ID_LEVEL 4301 +#define LG_ID_SEARCH 4302 +#define LG_ID_VIEW 4303 +#define LG_ID_COPY 4304 +#define LG_ID_FOLDER 4305 +#define LG_ID_STATUS 4306 +#define LG_ID_LBL_LEVEL 4307 +#define LG_ID_LBL_FIND 4308 +#define LG_TIMER 1 +#define LG_POLL_MS 1000 +#define LG_LINES_FIRST 2000 +#define LG_LINES_NEXT 500 +#define LG_CLIENT_W 820 +#define LG_CLIENT_H 520 +#define LG_DEFAULT_LEVEL 2 /* info and above: the agent's default log level */ + +static struct { + HWND wnd, level, search, view, btn_copy, btn_folder, status, lbl_level, lbl_find; + HFONT font, mono; + unsigned dpi; + ui_log_model model; + int in_flight; + int folder_hint; /* the "old agent" status is shown once per open */ +} g_lg; + +static int dpx(int v) +{ + return ui_dpi_scale(v, g_lg.dpi); +} + +static void status_text(const wchar_t *text) +{ + if (g_lg.status) + SetWindowTextW(g_lg.status, text ? text : L""); +} + +static void status_msg(sd_msg_id id) +{ + status_text(sd_l10n_get(id)); +} + +static void status_err(sd_msg_id id, sd_err err) +{ + wchar_t code[64]; + wchar_t *t; + ui_text_utf8(sd_err_name(err), code, SD_COUNTOF(code)); + t = ui_format1(id, "code", code); + if (t != NULL) { + status_text(t); + sd_free(t); + } +} + +static void status_count(sd_msg_id id, size_t n) +{ + wchar_t *t = ui_format_count(id, (long long)n); + if (t != NULL) { + status_text(t); + sd_free(t); + } +} + +static int level_max(void) +{ + int i = (int)SendMessageW(g_lg.level, CB_GETCURSEL, 0, 0); + return i < 0 ? LG_DEFAULT_LEVEL : i; +} + +/* Rebuilds the text of the view from the model and the filters. The view keeps its place: at the end it follows + new records, elsewhere it keeps the first visible line and the selection. */ +static void rebuild_view(void) +{ + wchar_t search[256]; + wchar_t *text = NULL; + size_t lines = 0; + int first, len; + DWORD sel_start = 0, sel_end = 0; + int at_end; + + GetWindowTextW(g_lg.search, search, SD_COUNTOF(search)); + if (ui_logs_build_text(&g_lg.model, level_max(), search, &text, &lines) != SD_OK) + return; + first = (int)SendMessageW(g_lg.view, EM_GETFIRSTVISIBLELINE, 0, 0); + SendMessageW(g_lg.view, EM_GETSEL, (WPARAM)&sel_start, (LPARAM)&sel_end); + len = GetWindowTextLengthW(g_lg.view); + at_end = len == 0 || (int)sel_end >= len; + + SendMessageW(g_lg.view, WM_SETREDRAW, FALSE, 0); + SetWindowTextW(g_lg.view, text); + if (at_end) { + int n = GetWindowTextLengthW(g_lg.view); + SendMessageW(g_lg.view, EM_SETSEL, (WPARAM)n, (LPARAM)n); + SendMessageW(g_lg.view, EM_SCROLLCARET, 0, 0); + } else { + int now = (int)SendMessageW(g_lg.view, EM_GETFIRSTVISIBLELINE, 0, 0); + SendMessageW(g_lg.view, EM_SETSEL, sel_start, sel_end); + if (first > now) + SendMessageW(g_lg.view, EM_LINESCROLL, 0, first - now); + } + SendMessageW(g_lg.view, WM_SETREDRAW, TRUE, 0); + InvalidateRect(g_lg.view, NULL, TRUE); + sd_free(text); + status_count(SD_MSG_UI_LOGS_STATUS_COUNT, lines); +} + +static void poll(void) +{ + char params[160]; + if (g_lg.wnd == NULL || !IsWindowVisible(g_lg.wnd) || IsIconic(g_lg.wnd)) + return; + if (g_lg.in_flight) + return; + if (!g_app.agent_up) { + status_msg(SD_MSG_UI_LOGS_AGENT_DOWN); + return; + } + if ((g_app.agent_features & UI_FEAT_LOG_TAIL) == 0) { + if (!g_lg.folder_hint) + status_msg(SD_MSG_UI_LOGS_OLD_AGENT); + g_lg.folder_hint = 1; + return; + } + if (g_lg.model.cursor[0] != '\0') + sprintf_s(params, sizeof(params), "{\"cursor\":\"%s\",\"lines\":%d}", g_lg.model.cursor, LG_LINES_NEXT); + else + sprintf_s(params, sizeof(params), "{\"lines\":%d}", LG_LINES_FIRST); + if (ui_ipc_call(UI_IPC_LOG, "log.tail", params) == SD_OK) + g_lg.in_flight = 1; +} + +void ui_logs_on_reply(sd_err err, const sd_json *result) +{ + size_t added = 0; + int before; + g_lg.in_flight = 0; + if (g_lg.wnd == NULL) + return; + if (err != SD_OK) { + if (err == SD_E_DISCONNECTED) + status_msg(SD_MSG_UI_LOGS_AGENT_DOWN); + else + status_err(SD_MSG_UI_LOGS_READ_FAILED, err); + return; + } + before = (int)g_lg.model.count; + if (ui_logs_model_apply(&g_lg.model, result, &added) != SD_OK) { + status_msg(SD_MSG_UI_LOGS_BAD_REPLY); + return; + } + if (added || before != (int)g_lg.model.count || g_lg.model.reset) + rebuild_view(); + if (g_lg.model.dropped > 0) + status_count(SD_MSG_UI_LOGS_DROPPED, (size_t)g_lg.model.dropped); +} + +void ui_logs_on_agent_state(int up) +{ + if (g_lg.wnd == NULL) + return; + if (!up) { + g_lg.in_flight = 0; + status_msg(SD_MSG_UI_LOGS_AGENT_DOWN); + } + g_lg.folder_hint = 0; + poll(); +} + +static void copy_selection(void) +{ + DWORD s = 0, e = 0; + int len = GetWindowTextLengthW(g_lg.view); + wchar_t *buf; + size_t n; + if (len <= 0) + return; + buf = (wchar_t *)sd_alloc(((size_t)len + 1) * sizeof(wchar_t)); + if (buf == NULL) + return; + GetWindowTextW(g_lg.view, buf, len + 1); + SendMessageW(g_lg.view, EM_GETSEL, (WPARAM)&s, (LPARAM)&e); + if (e > s && (int)e <= len) { + n = e - s; + ui_win_copy_text(g_lg.wnd, buf + s, n); + } else { + ui_win_copy_text(g_lg.wnd, buf, (size_t)len); + } + sd_free(buf); +} + +static void open_folder(void) +{ + wchar_t path[MAX_PATH]; + HINSTANCE r; + if (sd_config_logs_dir(path, SD_COUNTOF(path)) != SD_OK) { + status_msg(SD_MSG_UI_LOGS_OPEN_FAILED); + return; + } + r = ShellExecuteW(g_lg.wnd, L"open", path, NULL, NULL, SW_SHOWNORMAL); + if ((INT_PTR)r <= 32) + status_msg(SD_MSG_UI_LOGS_OPEN_FAILED); +} + +static void layout(HWND hwnd) +{ + RECT rc; + int m, rh, sh, bh, W, H, y, by, lw1, lw2, x; + if (!g_lg.view || !g_lg.status) + return; + GetClientRect(hwnd, &rc); + W = rc.right - rc.left; + H = rc.bottom - rc.top; + m = dpx(8); + rh = dpx(24); + sh = dpx(20); + bh = dpx(26); + + lw1 = ui_win_text_width(g_lg.font, sd_l10n_get(SD_MSG_UI_LOGS_LEVEL_LABEL)) + dpx(8); + MoveWindow(g_lg.lbl_level, m, m + dpx(4), lw1, rh, TRUE); + x = m + lw1; + MoveWindow(g_lg.level, x, m, dpx(230), dpx(200), TRUE); + x += dpx(230) + m; + lw2 = ui_win_text_width(g_lg.font, sd_l10n_get(SD_MSG_UI_LOGS_SEARCH_LABEL)) + dpx(8); + MoveWindow(g_lg.lbl_find, x, m + dpx(4), lw2, rh, TRUE); + x += lw2; + MoveWindow(g_lg.search, x, m, W - x - m > 0 ? W - x - m : 0, rh, TRUE); + + y = m + rh + m; + MoveWindow(g_lg.status, m, H - m - sh, W - 2 * m > 0 ? W - 2 * m : 0, sh, TRUE); + by = H - m - sh - m - bh; + MoveWindow(g_lg.btn_copy, m, by, ui_win_text_width(g_lg.font, sd_l10n_get(SD_MSG_UI_LOGS_BTN_COPY)) + dpx(32), + bh, TRUE); + x = m + ui_win_text_width(g_lg.font, sd_l10n_get(SD_MSG_UI_LOGS_BTN_COPY)) + dpx(32) + m; + MoveWindow(g_lg.btn_folder, x, by, + ui_win_text_width(g_lg.font, sd_l10n_get(SD_MSG_UI_LOGS_BTN_FOLDER)) + dpx(32), bh, TRUE); + MoveWindow(g_lg.view, m, y, W - 2 * m > 0 ? W - 2 * m : 0, by - m - y > 0 ? by - m - y : 0, TRUE); +} + +static void on_command(WORD id, WORD code) +{ + if (id == LG_ID_LEVEL && code == CBN_SELCHANGE) { + rebuild_view(); + } else if (id == LG_ID_SEARCH && code == EN_CHANGE) { + rebuild_view(); + } else if (id == LG_ID_COPY && code == BN_CLICKED) { + copy_selection(); + } else if (id == LG_ID_FOLDER && code == BN_CLICKED) { + open_folder(); + } +} + +static void create_children(HWND hwnd) +{ + HINSTANCE inst = g_app.inst; + size_t i; + static const sd_msg_id levels[5] = { + SD_MSG_UI_LOGS_LEVEL_ERROR, SD_MSG_UI_LOGS_LEVEL_WARN, SD_MSG_UI_LOGS_LEVEL_INFO, + SD_MSG_UI_LOGS_LEVEL_DEBUG, SD_MSG_UI_LOGS_LEVEL_TRACE + }; + + g_lg.lbl_level = CreateWindowExW(0, L"STATIC", sd_l10n_get(SD_MSG_UI_LOGS_LEVEL_LABEL), + WS_CHILD | WS_VISIBLE | SS_LEFT | SS_NOPREFIX, 0, 0, 10, 10, hwnd, + (HMENU)(UINT_PTR)LG_ID_LBL_LEVEL, inst, NULL); + g_lg.level = CreateWindowExW(0, L"COMBOBOX", L"", + WS_CHILD | WS_VISIBLE | WS_TABSTOP | WS_VSCROLL | CBS_DROPDOWNLIST, 0, 0, 10, 10, + hwnd, (HMENU)(UINT_PTR)LG_ID_LEVEL, inst, NULL); + g_lg.lbl_find = CreateWindowExW(0, L"STATIC", sd_l10n_get(SD_MSG_UI_LOGS_SEARCH_LABEL), + WS_CHILD | WS_VISIBLE | SS_LEFT | SS_NOPREFIX, 0, 0, 10, 10, hwnd, + (HMENU)(UINT_PTR)LG_ID_LBL_FIND, inst, NULL); + g_lg.search = CreateWindowExW(WS_EX_CLIENTEDGE, L"EDIT", L"", + WS_CHILD | WS_VISIBLE | WS_TABSTOP | ES_AUTOHSCROLL, 0, 0, 10, 10, hwnd, + (HMENU)(UINT_PTR)LG_ID_SEARCH, inst, NULL); + g_lg.view = CreateWindowExW(WS_EX_CLIENTEDGE, L"EDIT", L"", + WS_CHILD | WS_VISIBLE | WS_TABSTOP | WS_VSCROLL | WS_HSCROLL | ES_MULTILINE | + ES_READONLY | ES_AUTOVSCROLL | ES_AUTOHSCROLL | ES_NOHIDESEL, + 0, 0, 10, 10, hwnd, (HMENU)(UINT_PTR)LG_ID_VIEW, inst, NULL); + g_lg.btn_copy = CreateWindowExW(0, L"BUTTON", sd_l10n_get(SD_MSG_UI_LOGS_BTN_COPY), + WS_CHILD | WS_VISIBLE | WS_TABSTOP | BS_PUSHBUTTON, 0, 0, 10, 10, hwnd, + (HMENU)(UINT_PTR)LG_ID_COPY, inst, NULL); + g_lg.btn_folder = CreateWindowExW(0, L"BUTTON", sd_l10n_get(SD_MSG_UI_LOGS_BTN_FOLDER), + WS_CHILD | WS_VISIBLE | WS_TABSTOP | BS_PUSHBUTTON, 0, 0, 10, 10, hwnd, + (HMENU)(UINT_PTR)LG_ID_FOLDER, inst, NULL); + g_lg.status = CreateWindowExW(0, L"STATIC", L"", WS_CHILD | WS_VISIBLE | SS_LEFT | SS_NOPREFIX, 0, 0, 10, 10, + hwnd, (HMENU)(UINT_PTR)LG_ID_STATUS, inst, NULL); + + for (i = 0; i < 5; i++) + SendMessageW(g_lg.level, CB_ADDSTRING, 0, (LPARAM)sd_l10n_get(levels[i])); + SendMessageW(g_lg.level, CB_SETCURSEL, LG_DEFAULT_LEVEL, 0); + SendMessageW(g_lg.view, EM_SETLIMITTEXT, (WPARAM)0x7FFFFFFEu, 0); + SendMessageW(g_lg.view, WM_SETFONT, (WPARAM)g_lg.mono, MAKELPARAM(TRUE, 0)); + ui_win_apply_font(hwnd, g_lg.font); + SendMessageW(g_lg.view, WM_SETFONT, (WPARAM)g_lg.mono, MAKELPARAM(TRUE, 0)); +} + +static HFONT make_mono(unsigned dpi) +{ + return CreateFontW(-ui_dpi_scale(13, dpi), 0, 0, 0, FW_NORMAL, FALSE, FALSE, FALSE, DEFAULT_CHARSET, + OUT_DEFAULT_PRECIS, CLIP_DEFAULT_PRECIS, DEFAULT_QUALITY, FIXED_PITCH | FF_MODERN, + L"Courier New"); +} + +static void on_dpi(HWND hwnd, unsigned dpi, const RECT *suggested) +{ + HFONT old = g_lg.font, oldm = g_lg.mono; + if (dpi == 0) + dpi = UI_DPI_BASE; + g_lg.dpi = dpi; + if (suggested) + SetWindowPos(hwnd, NULL, suggested->left, suggested->top, suggested->right - suggested->left, + suggested->bottom - suggested->top, SWP_NOZORDER | SWP_NOACTIVATE); + g_lg.font = ui_win_font(dpi); + g_lg.mono = make_mono(dpi); + ui_win_apply_font(hwnd, g_lg.font); + SendMessageW(g_lg.view, WM_SETFONT, (WPARAM)g_lg.mono, MAKELPARAM(TRUE, 0)); + if (old) + DeleteObject(old); + if (oldm) + DeleteObject(oldm); + layout(hwnd); +} + +static LRESULT CALLBACK lg_proc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) +{ + switch (msg) { + case WM_CREATE: + g_lg.wnd = hwnd; + g_lg.dpi = ui_dpi_window(hwnd); + g_lg.font = ui_win_font(g_lg.dpi); + g_lg.mono = make_mono(g_lg.dpi); + create_children(hwnd); + layout(hwnd); + SetTimer(hwnd, LG_TIMER, LG_POLL_MS, NULL); + status_msg(g_app.agent_up ? SD_MSG_UI_LOGS_LOADING : SD_MSG_UI_LOGS_AGENT_DOWN); + return 0; + case WM_SIZE: + layout(hwnd); + return 0; + case WM_COMMAND: + on_command(LOWORD(wp), HIWORD(wp)); + return 0; + case WM_TIMER: + if (wp == LG_TIMER) + poll(); + return 0; + case WM_CLOSE: + DestroyWindow(hwnd); + return 0; + case WM_DPICHANGED: + on_dpi(hwnd, (unsigned)LOWORD(wp), (const RECT *)lp); + return 0; + case WM_DESTROY: + KillTimer(hwnd, LG_TIMER); + if (g_lg.font) + DeleteObject(g_lg.font); + if (g_lg.mono) + DeleteObject(g_lg.mono); + ui_logs_model_free(&g_lg.model); + memset(&g_lg, 0, sizeof(g_lg)); + return 0; + default: + break; + } + return DefWindowProcW(hwnd, msg, wp, lp); +} + +static HWND create_window(int visible) +{ + wchar_t cls[320]; + HWND w; + ui_win_class_name(L"logs", cls); + if (!ui_win_register(g_app.inst, cls, lg_proc)) + return NULL; + if (g_lg.wnd != NULL && IsWindow(g_lg.wnd)) + return g_lg.wnd; + w = CreateWindowExW(0, cls, sd_l10n_get(SD_MSG_UI_LOGS_TITLE), WS_OVERLAPPEDWINDOW | WS_CLIPCHILDREN, + CW_USEDEFAULT, CW_USEDEFAULT, LG_CLIENT_W, LG_CLIENT_H, NULL, NULL, g_app.inst, NULL); + if (w == NULL) + return NULL; + SetWindowPos(w, NULL, 0, 0, ui_dpi_scale(LG_CLIENT_W, ui_dpi_window(w)), + ui_dpi_scale(LG_CLIENT_H, ui_dpi_window(w)), SWP_NOMOVE | SWP_NOZORDER | SWP_NOACTIVATE); + if (visible) + ShowWindow(w, SW_SHOW); + return w; +} + +void ui_logs_open(void) +{ + HWND w; + if (g_lg.wnd != NULL && IsWindow(g_lg.wnd)) { + ShowWindow(g_lg.wnd, IsIconic(g_lg.wnd) ? SW_RESTORE : SW_SHOW); + SetForegroundWindow(g_lg.wnd); + poll(); + return; + } + ui_logs_model_init(&g_lg.model); + w = create_window(1); + if (w != NULL) + poll(); +} + +void ui_logs_close(void) +{ + if (g_lg.wnd != NULL && IsWindow(g_lg.wnd)) + DestroyWindow(g_lg.wnd); +} + +HWND ui_logs_create_hidden(void) +{ + return create_window(0); +} diff --git a/apps/shelldrive-ui/ui_logs_model.c b/apps/shelldrive-ui/ui_logs_model.c new file mode 100644 index 0000000..5e4f4b2 --- /dev/null +++ b/apps/shelldrive-ui/ui_logs_model.c @@ -0,0 +1,243 @@ +/* shelldrive-ui: the model of the Logs viewer (03 §10.1, §2.4.1). See ui_logs_model.h. + * + * Pure: no window or process calls. */ +#include +#include +#include +#include + +#include "sd_base/sd_mem.h" +#include "sd_base/sd_utf.h" +#include "ui_logs_model.h" + +static const char *const k_level_names[UI_LOG_LEVELS] = { "error", "warn", "info", "debug", "trace" }; +static const wchar_t *const k_level_wide[UI_LOG_LEVELS] = { L"ERROR", L"WARN", L"INFO", L"DEBUG", L"TRACE" }; + +void ui_logs_model_init(ui_log_model *m) +{ + memset(m, 0, sizeof(*m)); +} + +void ui_logs_model_clear(ui_log_model *m) +{ + size_t i; + for (i = 0; i < m->count; i++) + sd_free(m->rec[i].line); + m->count = 0; + m->cursor[0] = '\0'; + m->reset = 0; + m->dropped = 0; +} + +void ui_logs_model_free(ui_log_model *m) +{ + ui_logs_model_clear(m); + sd_free(m->rec); + memset(m, 0, sizeof(*m)); +} + +int ui_log_level_parse(const char *s) +{ + size_t i; + if (s == NULL) + return -1; + for (i = 0; i < UI_LOG_LEVELS; i++) + if (strcmp(s, k_level_names[i]) == 0) + return (int)i; + return -1; +} + +/* "