Skip to content
Draft
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
355 changes: 355 additions & 0 deletions docs/internals/windows-background-service.md

Large diffs are not rendered by default.

25 changes: 25 additions & 0 deletions native/windows-service-host/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

43 changes: 43 additions & 0 deletions native/windows-service-host/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
[package]
name = "t3-windows-service-host"
version = "0.1.0"
edition = "2024"
license = "MIT"
publish = false
description = "Minimal T3-owned Windows SCM host for the pinned service launcher."

[lib]
name = "t3_windows_service_host"
path = "src/lib.rs"

[[bin]]
name = "t3-windows-service-host"
path = "src/main.rs"

[features]
# Development and test only. Enables `--exec` so the host can spawn an arbitrary
# dummy child instead of the pinned `t3.exe __service-launcher`. A packaged
# build must never enable this: the production child is selected by `--runtime`.
test-child = []

# SCM dispatch, job objects and process creation are Windows-only. Gating the
# dependency by target keeps `cargo test` on a developer host dependency-free
# and lets the portable core compile and run without a Windows toolchain.
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.61.2", features = [
"Win32_Foundation",
"Win32_Security",
"Win32_Security_Authentication_Identity",
"Win32_Storage_FileSystem",
"Win32_System_Console",
"Win32_System_JobObjects",
"Win32_System_Services",
"Win32_System_Threading",
"Win32_System_WindowsProgramming",
] }

[profile.release]
codegen-units = 1
lto = "thin"
panic = "abort"
strip = true
28 changes: 28 additions & 0 deletions native/windows-service-host/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# t3-windows-service-host

Minimal T3-owned Windows SCM host. It runs the pinned `t3.exe
__service-launcher` under a job object and reports service state to the
service control manager. It is not a general-purpose service wrapper and does
not manage T3 updates; the launcher owns those.

See [docs/internals/windows-background-service.md](../../docs/internals/windows-background-service.md)
for the SCM contract, account constraints, the required launcher control
adaptation, and the native acceptance checklist.

## Build and test

```sh
# Portable core (developer host, no Windows toolchain needed):
cargo test --locked --manifest-path native/windows-service-host/Cargo.toml

# Type-check the Windows-only SCM and job-object module:
cargo check --locked --target x86_64-pc-windows-msvc \
--manifest-path native/windows-service-host/Cargo.toml

# Windows release binary (Windows host):
cargo build --locked --release --manifest-path native/windows-service-host/Cargo.toml
```

The SCM dispatch path is unqualified until the native recipe in the design doc
runs on a disposable Windows environment. `--console` exercises the same
supervisor against a terminal and is not SCM proof.
68 changes: 68 additions & 0 deletions native/windows-service-host/src/account.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
//! Qualified service-account identity matching.
//!
//! `GetUserNameW` returns a bare account name with no domain, so comparing a
//! potentially qualified `--expected-account` against it is ambiguous: the same
//! name can exist in several domains. When the binding is supplied the host
//! therefore proves identity against qualified forms (`DOMAIN\user` from
//! `NameSamCompatible`, `user@domain` from `NameUserPrincipal`). A bare expected
//! name is rejected at configuration time because it cannot prove which domain
//! the process runs as.

/// A qualified account carries a domain component.
pub fn is_qualified(account: &str) -> bool {
account.contains('\\') || account.contains('@')
}

/// True when the expected account matches either qualified identity form.
/// At least one identity form must be present; otherwise there is no proof.
pub fn qualified_match(expected: &str, sam: Option<&str>, upn: Option<&str>) -> bool {
[sam, upn]
.into_iter()
.flatten()
.any(|identity| identity.eq_ignore_ascii_case(expected))
}

/// True when a SAM account is LocalSystem. Used to refuse the default
/// LocalSystem workload; `--allow-local-system` is the explicit override.
pub fn is_local_system(sam: Option<&str>) -> bool {
sam.and_then(|sam| sam.rsplit('\\').next())
.is_some_and(|name| name.eq_ignore_ascii_case("SYSTEM"))
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn bare_names_are_not_qualified() {
assert!(!is_qualified("t3service"));
assert!(is_qualified(r"CORP\t3service"));
assert!(is_qualified("t3service@corp.example"));
}

#[test]
fn matches_sam_and_upn_case_insensitively() {
assert!(qualified_match(
r"CORP\t3service",
Some(r"corp\T3Service"),
None
));
assert!(qualified_match(
"t3service@corp.example",
None,
Some("T3Service@Corp.Example")
));
assert!(!qualified_match(
r"CORP\t3service",
Some(r"OTHER\t3service"),
None
));
}

#[test]
fn detects_local_system_from_the_sam_form() {
assert!(is_local_system(Some(r"NT AUTHORITY\SYSTEM")));
assert!(!is_local_system(Some(r"NT AUTHORITY\LocalService")));
assert!(!is_local_system(None));
}
}
189 changes: 189 additions & 0 deletions native/windows-service-host/src/admission.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
//! Scoped, checked process admission.
//!
//! `CreateProcessW` returns a *suspended* process. Three steps must all succeed
//! before the host may own it: assign it to the job object, capture its
//! creation-time identity, and resume its primary thread. A failure at any step
//! must reclaim the freshly created process explicitly — a suspended child left
//! outside the job is an orphan, and closing its handle does not terminate it.
//!
//! The native implementation retains the created process handle until the
//! cleanup outcome is known and reports that outcome with the failure. This
//! module is portable so each failure can be injected without Windows.

use crate::host::{CleanupOutcome, ProcessIdentity};

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AdmissionStage {
AssignToJob,
CaptureIdentity,
Resume,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AdmissionFailure {
pub stage: AdmissionStage,
pub reason: String,
/// What happened when the freshly created process was reclaimed. A failure
/// to confirm cleanup is itself part of the admission failure.
pub cleanup: CleanupOutcome,
}

/// The native operations an admission needs. Implemented over the real handles
/// in `windows::job`; tests inject each failure through a portable fake.
pub trait AdmissionOps {
fn assign_to_job(&mut self) -> Result<(), String>;
fn capture_identity(&mut self) -> Result<ProcessIdentity, String>;
fn resume(&mut self) -> Result<(), String>;
/// Terminate the freshly created process and report whether that is confirmed.
fn terminate_created(&mut self) -> CleanupOutcome;
}

pub fn admit<O: AdmissionOps>(ops: &mut O) -> Result<ProcessIdentity, AdmissionFailure> {
if let Err(reason) = ops.assign_to_job() {
return Err(AdmissionFailure {
stage: AdmissionStage::AssignToJob,
reason,
cleanup: ops.terminate_created(),
});
}
let identity = match ops.capture_identity() {
Ok(identity) => identity,
Err(reason) => {
return Err(AdmissionFailure {
stage: AdmissionStage::CaptureIdentity,
reason,
cleanup: ops.terminate_created(),
});
}
};
if let Err(reason) = ops.resume() {
return Err(AdmissionFailure {
stage: AdmissionStage::Resume,
reason,
cleanup: ops.terminate_created(),
});
}
Ok(identity)
}

#[cfg(test)]
mod tests {
use super::*;

#[derive(Default)]
struct FakeOps {
fail_assign: bool,
fail_identity: bool,
fail_resume: bool,
cleanup: Option<CleanupOutcome>,
terminated: bool,
}

impl AdmissionOps for FakeOps {
fn assign_to_job(&mut self) -> Result<(), String> {
if self.fail_assign {
Err("assign failed".to_owned())
} else {
Ok(())
}
}
fn capture_identity(&mut self) -> Result<ProcessIdentity, String> {
if self.fail_identity {
Err("creation time unavailable".to_owned())
} else {
Ok(ProcessIdentity {
pid: 42,
created_at_ms: 1_000,
})
}
}
fn resume(&mut self) -> Result<(), String> {
if self.fail_resume {
Err("resume failed".to_owned())
} else {
Ok(())
}
}
fn terminate_created(&mut self) -> CleanupOutcome {
self.terminated = true;
self.cleanup.unwrap_or(CleanupOutcome::Confirmed)
}
}

#[test]
fn success_does_not_terminate_the_created_process() {
let mut ops = FakeOps::default();
let identity = admit(&mut ops).expect("admission succeeds");
assert_eq!(identity.pid, 42);
assert!(
!ops.terminated,
"a successfully admitted process is not cleaned up"
);
}

#[test]
fn assignment_failure_reclaims_the_created_process() {
let mut ops = FakeOps {
fail_assign: true,
..FakeOps::default()
};
let failure = admit(&mut ops).unwrap_err();
assert_eq!(failure.stage, AdmissionStage::AssignToJob);
assert!(
ops.terminated,
"the suspended child must not be left orphaned"
);
assert_eq!(failure.cleanup, CleanupOutcome::Confirmed);
}

#[test]
fn identity_failure_reclaims_the_created_process() {
let mut ops = FakeOps {
fail_identity: true,
..FakeOps::default()
};
let failure = admit(&mut ops).unwrap_err();
assert_eq!(failure.stage, AdmissionStage::CaptureIdentity);
assert!(ops.terminated);
}

#[test]
fn resume_failure_reclaims_the_created_process() {
let mut ops = FakeOps {
fail_resume: true,
..FakeOps::default()
};
let failure = admit(&mut ops).unwrap_err();
assert_eq!(failure.stage, AdmissionStage::Resume);
assert!(ops.terminated);
}

#[test]
fn an_unconfirmed_cleanup_is_reported_with_the_failure() {
let mut ops = FakeOps {
fail_assign: true,
cleanup: Some(CleanupOutcome::Failed),
..FakeOps::default()
};
let failure = admit(&mut ops).unwrap_err();
assert_eq!(failure.cleanup, CleanupOutcome::Failed);
}

#[test]
fn a_timed_out_or_unknown_cleanup_is_reported_with_the_failure() {
for cleanup in [CleanupOutcome::Failed, CleanupOutcome::Unknown] {
let mut ops = FakeOps {
fail_resume: true,
cleanup: Some(cleanup),
..FakeOps::default()
};
let failure = admit(&mut ops).unwrap_err();
assert_eq!(failure.stage, AdmissionStage::Resume);
assert!(
ops.terminated,
"the created process is always reclaimed explicitly first"
);
assert_eq!(failure.cleanup, cleanup);
}
}
}
Loading
Loading