Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,11 @@ Report lands at `_build\windows-x86-64-coverage\coverage-report\index.html`.
## Architecture

- **`src/`** — holds no sources of its own; `src/CMakeLists.txt` only aggregates the two real targets below. There is no application executable: the deliverable is the DLL that IIS loads.
- `src/core/` is the IIS/Windows-independent layer: static library target `IISNativeModuleCore` (namespace `core`), exporting its headers as `<core/...>` via a PUBLIC include dir. It holds `Verdict` (the platform-free mirror of `REQUEST_NOTIFICATION_STATUS`), the interfaces `IRequest`/`IResponse`/`IContext`/`IEventProvider`/`IModule` mirroring their IIS counterparts, and `ModuleHandler` — the actual request logic (403 + reason `Forbidden by IISRequestLevelModule` for cooked URL paths ending in `/forbidden`, `X-IISRequestLevelModule: 1` header otherwise). Nothing in this directory may include Windows or IIS headers; HRESULT-shaped values travel as `std::int32_t`.
- `src/module/` is the IIS native module itself: target `IISNativeModule`, a `SHARED` library (DLL) that exports `RegisterModule` via `iis_native_module.def` and links `IISNativeModuleCore`. Files here — as in `src/core` and `test/core` — are named after the class they hold (`IISRequestLevelModule.h/.cpp` etc., PascalCase). `iis::IISRequestLevelModule` is a thin adapter: its constructor takes a non-owning `core::IModule*`, and `OnBeginRequest` wraps the live IIS objects in the `IISContext`/`IISRequest`/`IISResponse`/`IISEventProvider` adapters (one file pair per class), delegates to the injected module, and maps the returned `Verdict` back to a `REQUEST_NOTIFICATION_STATUS`. `iis::IISRequestLevelModuleFactory` owns the shared (stateless) `core::ModuleHandler` and injects it into every instance it creates; `register_module.cpp` subscribes to `RQ_BEGIN_REQUEST` only — add further `RQ_*` flags there as methods gain implementations.
- **`test/`** — GTest-based tests, target/project `IISNativeModuleTest`. Globs `test/*.cpp` and `test/core/*.cpp`, and links `IISNativeModuleCore`. `test/main.cpp` is a standard GTest `main()` running `RUN_ALL_TESTS()`. Mirror the `src/` layout under `test/` for new tests (one GTest `TEST` per behavior, wrapped in the same namespace as the code under test). `test/core/ModuleHandlerTest.cpp` exercises `core::ModuleHandler` through hand-rolled mocks of the core interfaces — the pattern to follow for testing module logic without IIS.
- `src/core/` is the IIS/Windows-independent layer: static library target `IISNativeModuleCore` (namespace `core`), exporting its headers as `<core/...>` via a PUBLIC include dir. It holds `Verdict` (the platform-free mirror of `REQUEST_NOTIFICATION_STATUS`), the C++20 **concepts** `Request`/`Response`/`Context`/`EventProvider` mirroring their IIS counterparts, `AsciiToLower` (locale-independent folding — never use `std::towlower` for URL matching), and `ModuleHandler` — the actual request logic (403 + reason `Forbidden by IISRequestLevelModule` for cooked URL paths ending in `/forbidden`, `X-IISRequestLevelModule: 1` header otherwise). Nothing in this directory may include Windows or IIS headers; HRESULT-shaped values travel as `std::int32_t`.
- **The layer is compile-time polymorphic, and deliberately so** (measured: see below). `ModuleHandler::OnBeginRequest` is a template constrained by the concepts, so adapters and mocks bind with no vtable and no allocation. It is the one thing that **must** live in `ModuleHandler.h`: a template has to be visible where it is instantiated, and it is instantiated with the IIS adapter types in `src/module`, which core must not know about. Everything not type-dependent — the `EndsWithForbidden` matching algorithm — lives in `ModuleHandler.cpp`, per the project's one-`.h`/`.cpp`-pair-per-class convention.
- `core::RawHttpRequest::absPath` is a **non-owning `std::wstring_view`** over the server's buffer, and `Response`'s `string_view` parameters carry a **null-termination precondition** (the IIS adapter forwards `.data()` straight to IIS rather than materializing a `std::string`); every call site passes views over string literals. Do not reintroduce copies here — that is where the cost was.
- `src/module/` is the IIS native module itself: target `IISNativeModule`, a `SHARED` library (DLL) that exports `RegisterModule` via `iis_native_module.def` and links `IISNativeModuleCore`. Files here — as in `src/core` and `test/core` — are named after the class they hold (`IISRequestLevelModule.h/.cpp` etc., PascalCase). The four adapters `IISContext`/`IISRequest`/`IISResponse`/`IISEventProvider` are each a `.h`/`.cpp` pair and each carries a `static_assert` proving it satisfies its core concept — with no base class to inherit from, that assert is the only place a broken contract surfaces, so keep it. `iis::IISRequestLevelModule` is a thin adapter: its constructor takes a non-owning `const core::ModuleHandler*`, and `OnBeginRequest` wraps the live IIS objects in the adapters, delegates, and maps the returned `Verdict` back to a `REQUEST_NOTIFICATION_STATUS`. `iis::IISRequestLevelModuleFactory` owns the shared (stateless, `const`) `core::ModuleHandler` and injects it into every instance it creates; `register_module.cpp` subscribes to `RQ_BEGIN_REQUEST` only — add further `RQ_*` flags there as methods gain implementations.
- **`test/`** — GTest-based tests, target/project `IISNativeModuleTest`. Globs `test/*.cpp` and `test/core/*.cpp`, and links `IISNativeModuleCore`. `test/main.cpp` is a standard GTest `main()` running `RUN_ALL_TESTS()`. Mirror the `src/` layout under `test/` for new tests (one GTest `TEST` per behavior, wrapped in the same namespace as the code under test). `test/core/ModuleHandlerTest.cpp` exercises `core::ModuleHandler` through hand-rolled mocks — the pattern to follow for testing module logic without IIS. The mocks satisfy the core concepts **by shape alone**: no base class, no `override`, no vtable, each confirmed by a `static_assert`.
- Tests include core headers as `<core/...>`, which resolves because `IISNativeModuleCore` exports `src/` as a PUBLIC include directory — not through any include path set on the test target itself.
- Because every target uses `file(GLOB ...)`, CMake must be **re-run** (re-configure, not just rebuild) after adding or removing source files for them to be picked up — GLOB results are cached at configure time.

Expand All @@ -78,5 +80,6 @@ Report lands at `_build\windows-x86-64-coverage\coverage-report\index.html`.
- Minimum CMake version required is 4.2 (unusually new — verify your local CMake before assuming a configure failure is a code issue). CI installs `cmake >= 4.2` explicitly since GitHub-hosted runners don't have it preinstalled.
- The toolchain pins Visual Studio 2026 (`Visual Studio 18 2026`); local build presets additionally pin an exact MSVC toolset version (`14.44.35207` for build presets, `host=x64,version=14.50` for the static-analysis configure preset) — build failures on other VS/MSVC versions are expected without adjusting `CMakePresets.json`. CI sidesteps this (see above).
- `vcpkg.json` still declares `fmt` and `spdlog` (and the top-level `CMakeLists.txt` still `find_package`s them), but no target links them any more — they are kept available for future logging/formatting work.
- **Why core is templates rather than virtual interfaces**: measured on branch `perf/dispatch-microbenchmark` (`bench/`, Release, 2M iterations x 7 reps, generic path, min ns per call including per-request adapter construction). Virtual interfaces copying into `std::wstring`/`std::string`: **139.00 ns**. Same interfaces, zero-copy + `AsciiToLower`: **22.49 ns**. Templates + zero-copy + `AsciiToLower`: **9.98 ns** — faster than the pre-refactor direct implementation (10.86 ns), because inline ASCII folding beats the `_wcsnicmp` CRT call. Copies cost ~116 ns, virtual dispatch ~4–13 ns: **the allocations were the story, dispatch was the tiebreaker.** Don't reintroduce either. Note the 9.98 ns figure came from a fully-inlined variant; here the adapters and `EndsWithForbidden` are in `.cpp` files, so cross-TU calls remain unless `/GL`+`/LTCG` is enabled — the copies and the vtable, which dominated, are gone either way.
- All targets compile with `/guard:cf` (Control Flow Guard, also passed at link) and, under MSVC, `/sdl` — hardening for a DLL that runs network-facing inside w3wp.exe. `IISRequestLevelModule::OnBeginRequest` wraps its body in `try/catch(...)`: IIS notification methods must never let a C++ exception escape (it would crash the worker process), so keep that boundary when adding new notifications.
- CI actions in `.github/workflows/ci.yml` are pinned by commit SHA (supply-chain); Dependabot (`.github/dependabot.yml`) proposes pin bumps. When adding an action, pin it by SHA with a `# vX` comment.
22 changes: 22 additions & 0 deletions src/core/AsciiToLower.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
#ifndef CORE_ASCII_TO_LOWER_H_
#define CORE_ASCII_TO_LOWER_H_

namespace core {
// Locale-independent ASCII case folding.
//
// Deliberately NOT std::towlower: that consults the active C locale,
// so the same URL can fold differently depending on process
// configuration (the Turkish dotted/dotless I is the classic way this
// breaks a security comparison). URL path matching must behave
// identically everywhere, so only A-Z is folded and every other code
// point — including non-ASCII — is left untouched.
//
// Being constexpr and branch-simple, it also inlines into the
// comparison loop instead of calling into the CRT once per character.
constexpr auto AsciiToLower(wchar_t character) -> wchar_t {
return (character >= L'A' && character <= L'Z')
? static_cast<wchar_t>(character - L'A' + L'a')
: character;
}
}
#endif // CORE_ASCII_TO_LOWER_H_
35 changes: 35 additions & 0 deletions src/core/Context.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
#ifndef CORE_CONTEXT_H_
#define CORE_CONTEXT_H_

#include <type_traits>
#include <utility>

#include "Request.h"
#include "Response.h"

namespace core {

namespace detail {
// GetRequest()/GetResponse() hand back pointers; this strips the
// pointer and cv-qualifiers so the concepts below can be applied
// to what they point at.
template <typename T>
using Pointee = std::remove_cvref_t<std::remove_pointer_t<T>>;
}

// Compile-time counterpart of IHttpContext: exposes a request and a
// response that themselves satisfy the Request/Response concepts.
//
// Both getters return nullable pointers, mirroring an IIS context
// whose request or response is unavailable.
template <typename T>
concept Context =
requires(T & context) {
context.GetRequest();
context.GetResponse();
} &&
Request<detail::Pointee<decltype(std::declval<T &>().GetRequest())>> &&
Response<detail::Pointee<decltype(std::declval<T &>().GetResponse())>>;

}
#endif // CORE_CONTEXT_H_
17 changes: 17 additions & 0 deletions src/core/EventProvider.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
#ifndef CORE_EVENT_PROVIDER_H_
#define CORE_EVENT_PROVIDER_H_

#include <concepts>
#include <cstdint>

namespace core {
// Compile-time counterpart of IHttpEventProvider.
//
// errorStatus carries the HRESULT-shaped error code as a plain 32-bit
// integer so core stays independent from <windows.h>.
template <typename T>
concept EventProvider = requires(T & provider) {
{ provider.SetErrorStatus(std::int32_t{}) } -> std::same_as<void>;
};
}
#endif // CORE_EVENT_PROVIDER_H_
19 changes: 0 additions & 19 deletions src/core/IContext.h

This file was deleted.

17 changes: 0 additions & 17 deletions src/core/IEventProvider.h

This file was deleted.

20 changes: 0 additions & 20 deletions src/core/IModule.h

This file was deleted.

23 changes: 0 additions & 23 deletions src/core/IRequest.h

This file was deleted.

25 changes: 0 additions & 25 deletions src/core/IResponse.h

This file was deleted.

72 changes: 16 additions & 56 deletions src/core/ModuleHandler.cpp
Original file line number Diff line number Diff line change
@@ -1,74 +1,34 @@
#include "ModuleHandler.h"

#include <cstdint>
#include <cwctype>
#include <cstddef>
#include <string_view>

namespace core {
#include "AsciiToLower.h"

namespace core::detail {

namespace {

constexpr std::wstring_view kForbiddenSuffix = L"/forbidden";
constexpr std::string_view kForbiddenReason = "Forbidden by IISRequestLevelModule";
constexpr std::string_view kHeaderName = "X-IISRequestLevelModule";
constexpr std::string_view kHeaderValue = "1";
constexpr int kForbiddenStatusCode = 403;
// HRESULT_FROM_WIN32(ERROR_ACCESS_DENIED) / E_ACCESSDENIED, spelled
// as a plain value so core stays independent from <windows.h>.
constexpr std::int32_t kAccessDeniedError =
static_cast<std::int32_t>(0x80070005U);

// Case-insensitive suffix check on the cooked URL path, which
// carries the path without the query string.
//
// SECURITY NOTE: this is demo logic, not an access-control
// mechanism. A suffix-only match doesn't block a subtree
// ("/forbidden/", "/forbidden/x" pass), and std::towlower is
// locale-dependent (invariant ASCII folding is what a security
// comparison would need). Replace the matching semantics before
// building any real ACL on this.
auto EndsWithForbidden(const RawHttpRequest * rawRequest) -> bool {
if (rawRequest == nullptr) {
return false;
}
const std::wstring_view path = rawRequest->absPath;
if (path.length() < kForbiddenSuffix.length()) {
return false;
}
const std::wstring_view tail = path.substr(path.length() - kForbiddenSuffix.length());
for (size_t i = 0; i < kForbiddenSuffix.length(); ++i) {
if (std::towlower(tail[i]) != std::towlower(kForbiddenSuffix[i])) {
return false;
}
}
return true;
}

}

auto ModuleHandler::OnBeginRequest(
IContext * context,
IEventProvider * provider) -> Verdict {
if (context == nullptr) {
return Verdict::Continue;
auto EndsWithForbidden(const RawHttpRequest * rawRequest) -> bool {
if (rawRequest == nullptr) {
return false;
}

IRequest * request = context->GetRequest();
IResponse * response = context->GetResponse();
if (request == nullptr || response == nullptr) {
return Verdict::Continue;
const std::wstring_view path = rawRequest->absPath;
if (path.length() < kForbiddenSuffix.length()) {
return false;
}

if (EndsWithForbidden(request->GetRawHttpRequest())) {
response->SetStatus(kForbiddenStatusCode, kForbiddenReason, kAccessDeniedError);
if (provider != nullptr) {
provider->SetErrorStatus(kAccessDeniedError);
const std::wstring_view tail =
path.substr(path.length() - kForbiddenSuffix.length());
for (std::size_t i = 0; i < kForbiddenSuffix.length(); ++i) {
if (AsciiToLower(tail[i]) != AsciiToLower(kForbiddenSuffix[i])) {
return false;
}
return Verdict::Finish;
}

response->SetHeader(kHeaderName, kHeaderValue);
return Verdict::Continue;
return true;
}

}
Loading
Loading