0.3.5-alpha.2

This commit is contained in:
2026-09-21 22:36:38 +02:00
parent 3e24b7840c
commit 0077ea8b4d
11 changed files with 753 additions and 8 deletions

View File

@@ -1,5 +1,5 @@
# file: Cargo.toml # file: Cargo.toml
# version: 95 # version: 96
[workspace] [workspace]
resolver = "3" resolver = "3"
@@ -16,6 +16,7 @@ members = [
"crates/common/game-logging-lib", "crates/common/game-logging-lib",
"crates/common/game-realtime-transport-lib", "crates/common/game-realtime-transport-lib",
"crates/common/game-realtime-websocket-lib", "crates/common/game-realtime-websocket-lib",
"crates/common/game-realtime-webtransport-lib",
"crates/apps/game-android-entrypoint", "crates/apps/game-android-entrypoint",
"crates/apps/game-reflex-poc-tauri", "crates/apps/game-reflex-poc-tauri",
"crates/apps/game-reflex-poc-wasm", "crates/apps/game-reflex-poc-wasm",
@@ -24,7 +25,7 @@ members = [
] ]
[workspace.package] [workspace.package]
version = "0.3.5-alpha.1" version = "0.3.5-alpha.2"
edition = "2024" edition = "2024"
license = "MIT" license = "MIT"
repository = "https://git.sasedev.com/Sasedev/games" repository = "https://git.sasedev.com/Sasedev/games"
@@ -33,6 +34,7 @@ publish = false
[workspace.dependencies] [workspace.dependencies]
futures-util = { version = "0.3.34", default-features = false } futures-util = { version = "0.3.34", default-features = false }
rcgen = { version = "0.14.10", default-features = false }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
sdl3 = "^0.20" sdl3 = "^0.20"
tracing = "0.1.44" tracing = "0.1.44"
@@ -44,7 +46,9 @@ tauri-build = "2"
tauri-plugin-tracing = "^0.3" tauri-plugin-tracing = "^0.3"
tokio = "1.53.1" tokio = "1.53.1"
tokio-tungstenite = { version = "0.30.0", default-features = false } tokio-tungstenite = { version = "0.30.0", default-features = false }
url = "2.5.8"
wasm-bindgen = "0.2" wasm-bindgen = "0.2"
web-transport-quinn = { version = "0.12.1", default-features = false }
[workspace.lints.rust] [workspace.lints.rust]
missing_docs = "warn" missing_docs = "warn"

View File

@@ -1,5 +1,5 @@
<!-- file: README.md --> <!-- file: README.md -->
<!-- version: 63 --> <!-- version: 64 -->
# games.sasedev # games.sasedev
@@ -27,9 +27,9 @@ Workspace expérimental puis productif pour des jeux multiplateformes principale
Version stable de référence : `0.3.4`. Version stable de référence : `0.3.4`.
Version active : `0.3.5-alpha.1`. `0.3.2` reste différée. Version active : `0.3.5-alpha.2`. `0.3.2` reste différée.
La stable `0.3.4` livre la première baseline realtime : contrat binaire transport-neutral, backend WebSocket Tokio/tokio-tungstenite, limites et deadlines, tests loopback/robustesse, smoke runtime localhost public et frontières de dépendances empêchant moteurs et gameplay de dépendre d'un backend concret. `0.3.5-alpha.1` ouvre le POC WebTransport/QUIC par un cadrage sans backend nouveau : audit de la stable, étude de l'écosystème actuel, choix de la famille `web-transport`, stratégie TLS/fallback, smoke tests et découpage fin avant implémentation. La stable `0.3.4` livre la première baseline realtime : contrat binaire transport-neutral, backend WebSocket Tokio/tokio-tungstenite, limites et deadlines, tests loopback/robustesse, smoke runtime localhost public et frontières de dépendances empêchant moteurs et gameplay de dépendre d'un backend concret. `0.3.5-alpha.2` introduit le backend WebTransport natif minimal : Quinn/HTTP/3, identité TLS locale ECDSA P-256 courte ou injectée, pin SHA-256 et établissement client/server loopback. Streams applicatifs, framing fiable et adaptation `RealtimeConnection` restent réservés à la tranche suivante.
Les deux premiers jeux sont des POC structurels : `game-reflex-poc` et `game-snake-poc`. Ils existent d'abord pour valider les frontières du workspace, le moteur, les assets et le packaging multiplateforme. Les deux premiers jeux sont des POC structurels : `game-reflex-poc` et `game-snake-poc`. Ils existent d'abord pour valider les frontières du workspace, le moteur, les assets et le packaging multiplateforme.
@@ -45,4 +45,4 @@ Les deux premiers jeux sont des POC structurels : `game-reflex-poc` et `game-sna
## Diagnostics et tests ## Diagnostics et tests
Les socles transverses `crates/common/game-assets-lib` et `crates/common/game-logging-lib` fournissent respectivement la résolution logique des assets et le tracing commun. Le realtime est séparé entre `game-realtime-transport-lib`, contrat binaire transport-neutral, et `game-realtime-websocket-lib`, backend Tokio/tokio-tungstenite ; leurs responsabilités et leur consommation sont documentées dans leurs README/USAGE locaux. `game-realtime-websocket-smoke` fournit la preuve runtime localhost hors harness de test. Les tests unitaires résident hors `src/` sous `unit_tests/`; les tests dintégration/environnement résident sous `tests/`. Les socles transverses `crates/common/game-assets-lib` et `crates/common/game-logging-lib` fournissent respectivement la résolution logique des assets et le tracing commun. Le realtime est séparé entre `game-realtime-transport-lib`, contrat binaire transport-neutral, `game-realtime-websocket-lib`, backend Tokio/tokio-tungstenite, et `game-realtime-webtransport-lib`, backend WebTransport/QUIC candidat dont la première frontière native couvre TLS/pinning et établissement de session. Leurs responsabilités sont documentées dans leurs README/USAGE locaux lorsqu'un guide d'usage est justifié. `game-realtime-websocket-smoke` fournit la preuve runtime localhost hors harness de test ; le smoke WebTransport public est planifié après le chemin fiable. Les tests unitaires résident hors `src/` sous `unit_tests/`; les tests dintégration/environnement résident sous `tests/`.

View File

@@ -0,0 +1,24 @@
# file: crates/common/game-realtime-webtransport-lib/Cargo.toml
# version: 1
[package]
name = "game-realtime-webtransport-lib"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
authors.workspace = true
publish.workspace = true
[dependencies]
game-realtime-transport-lib = { path = "../game-realtime-transport-lib" }
rcgen = { workspace = true, features = ["ring"] }
tracing.workspace = true
url.workspace = true
web-transport-quinn = { workspace = true, features = ["ring"] }
[dev-dependencies]
tokio = { workspace = true, features = ["macros", "rt", "time"] }
[lints]
workspace = true

View File

@@ -0,0 +1,35 @@
<!-- file: crates/common/game-realtime-webtransport-lib/README.md -->
<!-- version: 1 -->
# game-realtime-webtransport-lib
Backend WebTransport/QUIC candidat pour le realtime de `games.sasedev`.
## Responsabilité
La crate possède le transport WebTransport concret sans introduire de sémantique gameplay, room, joueur, tick ou snapshot. Son chemin natif repose sur `web-transport-quinn` et conserve les erreurs publiques dans `game-realtime-transport-lib`.
La première frontière disponible couvre :
- configuration client HTTPS avec pin SHA-256 exact ;
- identité serveur X.509 DER + clé privée PKCS#8 DER injectables ;
- génération locale d'une identité self-signed ECDSA P-256 à validité courte pour `localhost`, IPv4 loopback et IPv6 loopback ;
- bind UDP/QUIC sur adresse explicite ou port éphémère ;
- établissement HTTP/3 WebTransport client/server natif ;
- tracing sous `games::realtime::webtransport`.
## TLS de développement
`WebTransportServerIdentity::generate_loopback()` crée une identité en mémoire. La clé privée n'est ni écrite ni versionnée. Le certificat est valide sept jours, avec une petite marge de clock skew, et son SHA-256 est exposé à travers `WebTransportCertificateHash` afin que le client puisse utiliser le pinning fourni par `web-transport-quinn`.
Une identité préexistante peut être injectée en DER avec `WebTransportServerIdentity::from_pkcs8_der(...)`. La compatibilité certificat/clé est alors vérifiée par le builder TLS au bind du serveur.
Aucune option de désactivation globale de la vérification TLS n'est exposée.
## Frontières actuelles
Cette crate n'adapte pas encore une session vers `RealtimeConnection`. Elle n'ouvre pas encore le stream bidirectionnel applicatif principal et ne définit donc ni framing message, ni limites de payload, ni deadlines applicatives, ni datagram API commune.
Ces responsabilités doivent rester séparées de la seule preuve d'établissement natif afin que l'introduction de QUIC/TLS demeure testable indépendamment du futur framing fiable.
Le chemin natif sexécute sous un runtime Tokio fourni par le consommateur ; la crate ne crée ni runtime ni thread privé. Le chemin navigateur/WASM est distinct : aucun `cfg` WASM ni dépendance navigateur n'est requis par le backend natif actuel.

View File

@@ -0,0 +1,25 @@
// file: crates/common/game-realtime-webtransport-lib/src/lib.rs
// version: 1
#![warn(missing_docs)]
#![deny(unreachable_pub)]
#![forbid(unsafe_code)]
//! Native Quinn-backed WebTransport establishment for the games.sasedev realtime transport POC.
mod webtransport;
/// Re-export of one SHA-256 certificate fingerprint accepted by the native WebTransport client.
pub use self::webtransport::WebTransportCertificateHash;
/// Re-export of native client establishment configuration.
pub use self::webtransport::WebTransportClientConfig;
/// Re-export of a bound native WebTransport server listener.
pub use self::webtransport::WebTransportListener;
/// Re-export of native WebTransport server establishment configuration.
pub use self::webtransport::WebTransportServerConfig;
/// Re-export of a generated or injected WebTransport server identity.
pub use self::webtransport::WebTransportServerIdentity;
/// Re-export of an established native WebTransport session.
pub use self::webtransport::WebTransportSession;
/// Re-export of the native WebTransport client session constructor.
pub use self::webtransport::connect;

View File

@@ -0,0 +1,283 @@
// file: crates/common/game-realtime-webtransport-lib/src/webtransport.rs
// version: 1
const CERTIFICATE_HASH_SIZE: usize = 32;
const LOCAL_CERTIFICATE_CLOCK_SKEW: std::time::Duration = std::time::Duration::from_secs(60);
const LOCAL_CERTIFICATE_VALIDITY: std::time::Duration = std::time::Duration::from_secs(7 * 24 * 60 * 60);
const TRACING_TARGET: &str = "games::realtime::webtransport";
/// SHA-256 fingerprint of one certificate accepted by the native WebTransport client.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct WebTransportCertificateHash {
bytes: [u8; CERTIFICATE_HASH_SIZE],
}
impl WebTransportCertificateHash {
/// Creates a fingerprint from an already-computed SHA-256 digest.
#[must_use]
pub fn from_sha256(bytes: [u8; CERTIFICATE_HASH_SIZE]) -> Self {
return Self { bytes };
}
/// Returns the exact 32-byte SHA-256 digest.
#[must_use]
pub fn as_bytes(&self) -> &[u8; CERTIFICATE_HASH_SIZE] {
return &self.bytes;
}
}
/// Self-contained certificate/private-key identity used by a native WebTransport server.
pub struct WebTransportServerIdentity {
certificate_der: Vec<u8>,
private_key_pkcs8_der: Vec<u8>,
certificate_hash: WebTransportCertificateHash,
}
impl WebTransportServerIdentity {
/// Generates a short-lived self-signed ECDSA P-256 identity for localhost and loopback addresses.
pub fn generate_loopback() -> Result<Self, game_realtime_transport_lib::TransportError> {
let now = std::time::SystemTime::now();
let not_before = match now.checked_sub(LOCAL_CERTIFICATE_CLOCK_SKEW) {
Some(value) => value,
None => return Err(invalid_configuration("failed to compute local certificate not-before time")),
};
let not_after = match now.checked_add(LOCAL_CERTIFICATE_VALIDITY) {
Some(value) => value,
None => return Err(invalid_configuration("failed to compute local certificate not-after time")),
};
let subject_alt_names = vec!["localhost".to_owned(), "127.0.0.1".to_owned(), "::1".to_owned()];
let mut params = match rcgen::CertificateParams::new(subject_alt_names) {
Ok(value) => value,
Err(error) => return Err(invalid_configuration(error.to_string())),
};
params.not_before = not_before.into();
params.not_after = not_after.into();
let key_pair = match rcgen::KeyPair::generate_for(&rcgen::PKCS_ECDSA_P256_SHA256) {
Ok(value) => value,
Err(error) => return Err(invalid_configuration(error.to_string())),
};
let certificate = match params.self_signed(&key_pair) {
Ok(value) => value,
Err(error) => return Err(invalid_configuration(error.to_string())),
};
let certificate_der = certificate.der().to_vec();
let private_key_pkcs8_der = key_pair.serialize_der();
return Self::from_pkcs8_der(certificate_der, private_key_pkcs8_der);
}
/// Builds an identity from an X.509 certificate DER blob and its PKCS#8 private key DER blob.
///
/// Certificate/key compatibility is validated by the native TLS server builder when the listener is bound.
pub fn from_pkcs8_der(certificate_der: Vec<u8>, private_key_pkcs8_der: Vec<u8>) -> Result<Self, game_realtime_transport_lib::TransportError> {
if certificate_der.is_empty() {
return Err(invalid_configuration("certificate DER must not be empty"));
}
if private_key_pkcs8_der.is_empty() {
return Err(invalid_configuration("PKCS#8 private-key DER must not be empty"));
}
let certificate_hash = match certificate_hash(certificate_der.as_slice()) {
Ok(value) => value,
Err(error) => return Err(error),
};
return Ok(Self { certificate_der, private_key_pkcs8_der, certificate_hash });
}
/// Returns the SHA-256 certificate fingerprint used for native hash pinning.
#[must_use]
pub fn certificate_hash(&self) -> &WebTransportCertificateHash {
return &self.certificate_hash;
}
}
/// Native WebTransport client endpoint and pinned server-certificate fingerprint.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct WebTransportClientConfig {
endpoint: url::Url,
certificate_hash: WebTransportCertificateHash,
}
impl WebTransportClientConfig {
/// Parses and validates a secure WebTransport endpoint with one pinned SHA-256 certificate fingerprint.
pub fn new(endpoint: &str, certificate_hash: WebTransportCertificateHash) -> Result<Self, game_realtime_transport_lib::TransportError> {
let parsed = match url::Url::parse(endpoint) {
Ok(value) => value,
Err(error) => return Err(invalid_configuration(error.to_string())),
};
if parsed.scheme() != "https" {
return Err(invalid_configuration("WebTransport endpoint scheme must be https"));
}
if parsed.host().is_none() {
return Err(invalid_configuration("WebTransport endpoint must contain a host"));
}
return Ok(Self { endpoint: parsed, certificate_hash });
}
/// Returns the validated WebTransport endpoint URL.
#[must_use]
pub fn endpoint(&self) -> &str {
return self.endpoint.as_str();
}
/// Returns the pinned SHA-256 server-certificate fingerprint.
#[must_use]
pub fn certificate_hash(&self) -> &WebTransportCertificateHash {
return &self.certificate_hash;
}
}
/// Native WebTransport server bind address and TLS identity.
pub struct WebTransportServerConfig {
bind_address: std::net::SocketAddr,
identity: WebTransportServerIdentity,
}
impl WebTransportServerConfig {
/// Creates native server configuration for the requested bind address and TLS identity.
#[must_use]
pub fn new(bind_address: std::net::SocketAddr, identity: WebTransportServerIdentity) -> Self {
return Self { bind_address, identity };
}
/// Returns the requested UDP bind address.
#[must_use]
pub fn bind_address(&self) -> std::net::SocketAddr {
return self.bind_address;
}
/// Returns the server certificate fingerprint that clients must pin for this identity.
#[must_use]
pub fn certificate_hash(&self) -> &WebTransportCertificateHash {
return self.identity.certificate_hash();
}
}
/// Established native WebTransport session before application-stream adaptation.
pub struct WebTransportSession {
inner: web_transport_quinn::Session,
}
impl WebTransportSession {
fn new(inner: web_transport_quinn::Session) -> Self {
return Self { inner };
}
/// Returns the remote UDP socket address backing the established QUIC connection.
#[must_use]
pub fn remote_addr(&self) -> std::net::SocketAddr {
return self.inner.remote_address();
}
/// Returns the HTTP/3 CONNECT URL used to establish this session when available.
#[must_use]
pub fn request_url(&self) -> Option<&str> {
return match self.inner.request() {
Some(request) => Some(request.url.as_str()),
None => None,
};
}
}
/// Bound native WebTransport server endpoint that accepts HTTP/3 WebTransport sessions.
pub struct WebTransportListener {
server: web_transport_quinn::Server,
local_addr: std::net::SocketAddr,
}
impl WebTransportListener {
/// Binds a native WebTransport server using TLS 1.3 and the configured certificate identity.
pub fn bind(config: WebTransportServerConfig) -> Result<Self, game_realtime_transport_lib::TransportError> {
let certificate = web_transport_quinn::quinn::rustls::pki_types::CertificateDer::from(config.identity.certificate_der);
let private_key = web_transport_quinn::quinn::rustls::pki_types::PrivatePkcs8KeyDer::from(config.identity.private_key_pkcs8_der);
let private_key = web_transport_quinn::quinn::rustls::pki_types::PrivateKeyDer::Pkcs8(private_key);
let server = match web_transport_quinn::ServerBuilder::new().with_addr(config.bind_address).with_certificate(vec![certificate], private_key) {
Ok(value) => value,
Err(error) => {
let mapped = transport_error(game_realtime_transport_lib::TransportErrorKind::Bind, error.to_string());
tracing::warn!(target: TRACING_TARGET, address = %config.bind_address, detail = mapped.detail(), "WebTransport listener bind failed");
return Err(mapped);
},
};
let local_addr = match server.local_addr() {
Ok(value) => value,
Err(error) => {
let mapped = transport_error(game_realtime_transport_lib::TransportErrorKind::Bind, error.to_string());
tracing::warn!(target: TRACING_TARGET, detail = mapped.detail(), "bound WebTransport listener address lookup failed");
return Err(mapped);
},
};
tracing::info!(target: TRACING_TARGET, address = %local_addr, "WebTransport listener bound");
return Ok(Self { server, local_addr });
}
/// Returns the concrete UDP socket address, including an ephemeral port selected by the OS.
#[must_use]
pub fn local_addr(&self) -> std::net::SocketAddr {
return self.local_addr;
}
/// Accepts one native WebTransport CONNECT request and returns the established session.
pub async fn accept(&mut self) -> Result<WebTransportSession, game_realtime_transport_lib::TransportError> {
let request = match self.server.accept().await {
Some(value) => value,
None => {
let error = transport_error(game_realtime_transport_lib::TransportErrorKind::Accept, "WebTransport server stopped accepting sessions");
tracing::warn!(target: TRACING_TARGET, detail = error.detail(), "WebTransport accept ended");
return Err(error);
},
};
let peer = request.conn().remote_address();
let session = match request.ok().await {
Ok(value) => value,
Err(error) => {
let mapped = transport_error(game_realtime_transport_lib::TransportErrorKind::Accept, error.to_string());
tracing::warn!(target: TRACING_TARGET, peer = %peer, detail = mapped.detail(), "WebTransport server handshake failed");
return Err(mapped);
},
};
tracing::info!(target: TRACING_TARGET, peer = %peer, "WebTransport peer accepted");
return Ok(WebTransportSession::new(session));
}
}
/// Establishes one native WebTransport session using an exact SHA-256 certificate pin.
pub async fn connect(config: &WebTransportClientConfig) -> Result<WebTransportSession, game_realtime_transport_lib::TransportError> {
let client = match web_transport_quinn::ClientBuilder::new().with_server_certificate_hashes(vec![config.certificate_hash.as_bytes().to_vec()]) {
Ok(value) => value,
Err(error) => return Err(invalid_configuration(error.to_string())),
};
let session = match client.connect(config.endpoint.clone()).await {
Ok(value) => value,
Err(error) => {
let mapped = transport_error(game_realtime_transport_lib::TransportErrorKind::Connect, error.to_string());
tracing::warn!(target: TRACING_TARGET, endpoint = config.endpoint.as_str(), detail = mapped.detail(), "WebTransport client connection failed");
return Err(mapped);
},
};
tracing::info!(target: TRACING_TARGET, endpoint = config.endpoint.as_str(), peer = %session.remote_address(), "WebTransport client connected");
return Ok(WebTransportSession::new(session));
}
fn certificate_hash(certificate_der: &[u8]) -> Result<WebTransportCertificateHash, game_realtime_transport_lib::TransportError> {
let certificate = web_transport_quinn::quinn::rustls::pki_types::CertificateDer::from(certificate_der.to_vec());
let provider = web_transport_quinn::crypto::default_provider();
let digest = web_transport_quinn::crypto::sha256(&provider, &certificate);
let digest_bytes = digest.as_ref();
if digest_bytes.len() != CERTIFICATE_HASH_SIZE {
return Err(invalid_configuration("WebTransport certificate SHA-256 digest has an unexpected length"));
}
let mut bytes = [0_u8; CERTIFICATE_HASH_SIZE];
bytes.copy_from_slice(digest_bytes);
return Ok(WebTransportCertificateHash::from_sha256(bytes));
}
fn invalid_configuration(detail: impl Into<String>) -> game_realtime_transport_lib::TransportError {
return transport_error(game_realtime_transport_lib::TransportErrorKind::InvalidConfiguration, detail);
}
fn transport_error(kind: game_realtime_transport_lib::TransportErrorKind, detail: impl Into<String>) -> game_realtime_transport_lib::TransportError {
return game_realtime_transport_lib::TransportError::new(kind, detail);
}
#[cfg(test)]
#[path = "../unit_tests/webtransport.rs"]
mod tests;

View File

@@ -0,0 +1,67 @@
// file: crates/common/game-realtime-webtransport-lib/tests/establishment.rs
// version: 1
//! Deterministic native loopback proof for WebTransport session establishment and SHA-256 pinning.
const TEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
#[tokio::test(flavor = "current_thread")]
async fn pinned_client_and_server_establish_a_loopback_session() {
let identity = match game_realtime_webtransport_lib::WebTransportServerIdentity::generate_loopback() {
Ok(value) => value,
Err(error) => panic!("loopback identity generation failed: {error}"),
};
let certificate_hash = identity.certificate_hash().clone();
let server_config = game_realtime_webtransport_lib::WebTransportServerConfig::new(std::net::SocketAddr::from(([127, 0, 0, 1], 0)), identity);
let mut listener = match game_realtime_webtransport_lib::WebTransportListener::bind(server_config) {
Ok(value) => value,
Err(error) => panic!("WebTransport listener bind failed: {error}"),
};
let endpoint = format!("https://{}/establishment", listener.local_addr());
let client_config = match game_realtime_webtransport_lib::WebTransportClientConfig::new(endpoint.as_str(), certificate_hash) {
Ok(value) => value,
Err(error) => panic!("WebTransport client configuration failed: {error}"),
};
let pair = tokio::time::timeout(TEST_TIMEOUT, async {
return tokio::join!(listener.accept(), game_realtime_webtransport_lib::connect(&client_config));
})
.await;
let (server_session, client_session) = match pair {
Ok((Ok(server), Ok(client))) => (server, client),
Ok((Err(error), _)) => panic!("WebTransport server establishment failed: {error}"),
Ok((_, Err(error))) => panic!("WebTransport client establishment failed: {error}"),
Err(_) => panic!("WebTransport loopback establishment timed out"),
};
assert_eq!(client_session.request_url(), Some(endpoint.as_str()));
assert_eq!(server_session.request_url(), Some(endpoint.as_str()));
assert_eq!(client_session.remote_addr(), listener.local_addr());
}
#[tokio::test(flavor = "current_thread")]
async fn incorrect_certificate_pin_rejects_establishment() {
let identity = match game_realtime_webtransport_lib::WebTransportServerIdentity::generate_loopback() {
Ok(value) => value,
Err(error) => panic!("loopback identity generation failed: {error}"),
};
let server_config = game_realtime_webtransport_lib::WebTransportServerConfig::new(std::net::SocketAddr::from(([127, 0, 0, 1], 0)), identity);
let mut listener = match game_realtime_webtransport_lib::WebTransportListener::bind(server_config) {
Ok(value) => value,
Err(error) => panic!("WebTransport listener bind failed: {error}"),
};
let endpoint = format!("https://{}/wrong-pin", listener.local_addr());
let wrong_hash = game_realtime_webtransport_lib::WebTransportCertificateHash::from_sha256([0_u8; 32]);
let client_config = match game_realtime_webtransport_lib::WebTransportClientConfig::new(endpoint.as_str(), wrong_hash) {
Ok(value) => value,
Err(error) => panic!("WebTransport client configuration failed: {error}"),
};
let (server_result, client_result) = tokio::join!(
tokio::time::timeout(TEST_TIMEOUT, listener.accept()),
tokio::time::timeout(TEST_TIMEOUT, game_realtime_webtransport_lib::connect(&client_config)),
);
match client_result {
Ok(Ok(_)) => panic!("WebTransport establishment unexpectedly accepted an incorrect certificate pin"),
Ok(Err(error)) => assert_eq!(error.kind(), game_realtime_transport_lib::TransportErrorKind::Connect),
Err(_) => panic!("incorrect-pin connection attempt timed out"),
}
assert!(matches!(server_result, Err(_) | Ok(Err(_))));
}

View File

@@ -0,0 +1,49 @@
// file: crates/common/game-realtime-webtransport-lib/unit_tests/webtransport.rs
// version: 1
#[test]
fn certificate_hash_preserves_exact_sha256_bytes() {
let bytes = [7_u8; super::CERTIFICATE_HASH_SIZE];
let hash = super::WebTransportCertificateHash::from_sha256(bytes);
assert_eq!(hash.as_bytes(), &bytes);
}
#[test]
fn client_config_accepts_https_and_rejects_non_secure_schemes() {
let hash = super::WebTransportCertificateHash::from_sha256([1_u8; super::CERTIFICATE_HASH_SIZE]);
let secure = super::WebTransportClientConfig::new("https://127.0.0.1:4433/game", hash.clone());
assert!(secure.is_ok());
let insecure_http = super::WebTransportClientConfig::new("http://127.0.0.1:4433/game", hash.clone());
match insecure_http {
Ok(_) => panic!("HTTP endpoint unexpectedly accepted"),
Err(error) => assert_eq!(error.kind(), game_realtime_transport_lib::TransportErrorKind::InvalidConfiguration),
}
let websocket = super::WebTransportClientConfig::new("ws://127.0.0.1:4433/game", hash);
match websocket {
Ok(_) => panic!("WebSocket endpoint unexpectedly accepted"),
Err(error) => assert_eq!(error.kind(), game_realtime_transport_lib::TransportErrorKind::InvalidConfiguration),
}
}
#[test]
fn injected_identity_rejects_empty_certificate_or_key() {
let missing_certificate = super::WebTransportServerIdentity::from_pkcs8_der(Vec::new(), vec![1]);
match missing_certificate {
Ok(_) => panic!("empty certificate unexpectedly accepted"),
Err(error) => assert_eq!(error.kind(), game_realtime_transport_lib::TransportErrorKind::InvalidConfiguration),
}
let missing_key = super::WebTransportServerIdentity::from_pkcs8_der(vec![1], Vec::new());
match missing_key {
Ok(_) => panic!("empty private key unexpectedly accepted"),
Err(error) => assert_eq!(error.kind(), game_realtime_transport_lib::TransportErrorKind::InvalidConfiguration),
}
}
#[test]
fn generated_loopback_identity_has_sha256_fingerprint() {
let identity = match super::WebTransportServerIdentity::generate_loopback() {
Ok(value) => value,
Err(error) => panic!("loopback identity generation failed: {error}"),
};
assert_eq!(identity.certificate_hash().as_bytes().len(), super::CERTIFICATE_HASH_SIZE);
}

203
deltas/0.3.5/alpha.2.md Normal file
View File

@@ -0,0 +1,203 @@
<!-- file: deltas/0.3.5/alpha.2.md -->
<!-- version: 2 -->
# Delta 0.3.5-alpha.2
## Base
Base : `0.3.5-alpha.1` validée par l'utilisateur le 2026-09-21.
Cette tranche reste limitée à la fondation WebTransport native prévue par le plan : dépendances minimales, identité TLS, pin SHA-256, bind QUIC/HTTP3 et établissement d'une session client/server. Elle ne contient encore ni stream applicatif principal, ni framing `u32 + payload`, ni adaptation `RealtimeConnection`, ni datagram, ni fallback.
## Historique fermé
Ajout de :
```text
history/0.3.5/alpha.1.md
```
L'entrée enregistre exactement la gate utilisateur reçue : fmt check, trois audits propres et `cargo check --workspace` propre sur `0.3.5-alpha.1`. Le plan révisé a été explicitement accepté avant le passage à cette tranche.
## Version
La version workspace passe de :
```text
0.3.5-alpha.1
```
à :
```text
0.3.5-alpha.2
```
Aucune version Android, npm ou Tauri indépendante n'est modifiée.
## Nouvelle crate WebTransport
Ajout de :
```text
crates/common/game-realtime-webtransport-lib/Cargo.toml
crates/common/game-realtime-webtransport-lib/README.md
crates/common/game-realtime-webtransport-lib/src/lib.rs
crates/common/game-realtime-webtransport-lib/src/webtransport.rs
crates/common/game-realtime-webtransport-lib/unit_tests/webtransport.rs
crates/common/game-realtime-webtransport-lib/tests/establishment.rs
```
La crate est placée sous `crates/common/` au même niveau que le contrat transport-neutral et le backend WebSocket. Elle ne contient aucune sémantique de jeu.
## Dépendances et features
Les contraintes nouvelles sont centralisées dans `[workspace.dependencies]` :
```text
rcgen = 0.14.10, default-features = false
url = 2.5.8
web-transport-quinn = 0.12.1, default-features = false
```
La crate consommatrice active localement uniquement `ring` sur `rcgen` et `web-transport-quinn`.
`web-transport-quinn` est utilisé directement dans cette tranche native. La façade multiplateforme `web-transport` reste différée jusqu'au chemin WASM, afin de ne pas introduire une dépendance sans consommateur réel.
Le backend crypto par défaut `aws-lc-rs` de `web-transport-quinn` est donc désactivé. `ring` devient le provider unique du POC natif initial.
## Identité TLS et pinning
`WebTransportServerIdentity` accepte deux chemins :
```text
generate_loopback()
from_pkcs8_der(certificate_der, private_key_pkcs8_der)
```
La génération loopback produit en mémoire :
- une clé ECDSA P-256 ;
- un certificat self-signed avec SHA-256 ;
- les SAN `localhost`, `127.0.0.1` et `::1` ;
- une validité de sept jours avec 60 secondes de marge avant l'heure courante ;
- aucun PEM ni fichier de clé versionné.
`WebTransportCertificateHash` porte exactement les 32 octets SHA-256 du certificat. Le client configure `ClientBuilder::with_server_certificate_hashes(...)` ; aucune option de TLS permissif n'est exposée.
L'injection DER ne prétend pas parser ou certifier la cohérence clé/certificat avant le bind : le builder TLS natif reste l'autorité qui rejette une paire incompatible.
## Configuration et établissement natif
`WebTransportClientConfig` impose un endpoint `https://` valide et un hash épinglé.
`WebTransportServerConfig` possède l'adresse UDP et l'identité TLS.
`WebTransportListener::bind(...)` :
- construit le serveur Quinn/WebTransport ;
- supporte le port `0` pour une allocation éphémère ;
- expose l'adresse effectivement bindée ;
- mappe les erreurs vers `TransportErrorKind::Bind`.
`WebTransportListener::accept(...)` accepte le CONNECT HTTP/3 et retourne une `WebTransportSession`.
`connect(...)` construit un client pinned et retourne également une `WebTransportSession`. Les erreurs d'établissement client sont mappées vers `Connect`; les erreurs serveur vers `Accept`.
La session expose uniquement des diagnostics d'établissement (`remote_addr`, URL CONNECT). Le stream fiable applicatif appartient explicitement à `alpha.3`.
## Tests ajoutés
Les tests unitaires couvrent :
- conservation exacte d'un SHA-256 de 32 octets ;
- acceptation d'un endpoint HTTPS ;
- rejet HTTP/WebSocket ;
- rejet d'une identité injectée sans certificat ou sans clé ;
- génération d'une identité loopback avec fingerprint SHA-256.
Le test d'intégration `establishment.rs` couvre :
- bind sur `127.0.0.1:0` ;
- génération d'identité éphémère ;
- pin SHA-256 transmis au client ;
- établissement client/server concurrent borné par timeout ;
- URL CONNECT observée des deux côtés ;
- rejet d'un mauvais pin côté client.
Il ne transmet volontairement aucun payload : ce serait anticiper `alpha.3`.
## Tracing
Le nouveau backend utilise :
```text
games::realtime::webtransport
```
pour bind, connexion, accept et diagnostics d'échec d'établissement.
## Documentation et plan
`README.md` racine annonce `0.3.5-alpha.2` et la nouvelle frontière native.
Le README local documente la responsabilité de la crate, le TLS de développement et les frontières encore exclues.
Le plan `005` est réconcilié avec les choix réellement fermés : dépendances natives directes, provider `ring`, identité sept jours et absence volontaire de façade `web-transport` avant le chemin WASM.
`ROADMAP.md` et `CHANGELOG.md` restent inchangés : le scope macro de `0.3.5` ne change pas et cette alpha n'est pas un jalon de changelog.
## Validation exécutée dans l'environnement de génération
L'environnement de génération ne possède pas de toolchain Rust. Il ne doit donc attribuer aucun `cargo fmt`, `cargo check`, Clippy, test ou `cargo tree` à cette livraison.
Les audits Python et contrôles statiques sont exécutés après constitution du delta. Sur la reconstruction locale issue du ZIP taggé `v0.3.4` puis du delta `alpha.1`, ils donnent :
```text
General Rust rule audit: clean
Rust export completeness audit: 0 candidate(s)
games.sasedev workspace audit: clean
Markdown table audit: clean (5 table(s), 269 file(s))
Distribution layout audit: clean (50 required path(s), 8 forbidden path(s) absent)
TOML/workspace consistency: clean
```
Le compteur de fichiers Markdown de cette reconstruction n'est pas utilisé comme référence pour le checkout utilisateur : la gate `alpha.1` de l'utilisateur comptait déjà davantage de fichiers (`282`) que la reconstruction autoritaire ZIP + delta. Seul le statut clean est comparé. Ces contrôles restent distincts de la gate Cargo utilisateur.
## Validation utilisateur demandée
Cette tranche modifie Rust et le graphe de dépendances ; la gate ciblée est donc :
```bash
cargo fmt --all
cargo fmt --all -- --check
python3 scripts/audit_rust_workspace_rules.py
python3 scripts/audit_markdown_tables.py README.md RULES.md ROADMAP.md CHANGELOG.md docs prompts crates Android Web deltas history
python3 scripts/audit_distribution_layout.py
cargo check --workspace
cargo clippy --workspace --all-targets --all-features -- -D warnings
cargo test -p game-realtime-webtransport-lib --all-targets --all-features
cargo tree -p game-realtime-webtransport-lib --edges normal
cargo tree -p game-realtime-webtransport-lib --edges features
cargo tree -i web-transport-quinn --workspace --edges normal
```
Le full `cargo test --workspace --all-targets --all-features` reste réservé au jalon large `beta.1` conformément au plan.
Aucun smoke executable n'est requis dans `alpha.2` : le test d'intégration prouve l'établissement natif sous harness, tandis que le smoke WebTransport public reste la responsabilité explicite de `alpha.5` après le framing fiable et la robustesse.
## Suite après validation
Si la gate est propre, `0.3.5-alpha.3` peut :
- ouvrir/ accepter le stream bidirectionnel principal ;
- ajouter le framing privé borné `u32 big-endian + payload` ;
- implémenter `RealtimeConnection`, sender et receiver ;
- prouver le round-trip binaire ordonné et plusieurs messages ;
- conserver datagrams, robustesse avancée et smoke public hors de cette tranche.
Un défaut fermé de cette tranche produit d'abord `0.3.5-alpha.2.fix.N` au lieu d'ouvrir `alpha.3`.

View File

@@ -1,11 +1,11 @@
<!-- file: docs/plans/005-V0_3_5_WEBTRANSPORT_QUIC_POC_PLAN.md --> <!-- file: docs/plans/005-V0_3_5_WEBTRANSPORT_QUIC_POC_PLAN.md -->
<!-- version: 1 --> <!-- version: 2 -->
# Plan 0.3.5 — POC WebTransport/QUIC et fallback WebSocket # Plan 0.3.5 — POC WebTransport/QUIC et fallback WebSocket
## Statut ## Statut
Plan actif créé pendant `0.3.5-alpha.1` à partir de l'archive taggée `v0.3.4`. Plan actif créé pendant `0.3.5-alpha.1` à partir de l'archive taggée `v0.3.4`, puis réconcilié pour l'implémentation native de `0.3.5-alpha.2`.
Le cadrage détaillé et la comparaison des stacks actuelles sont conservés dans `docs/studies/026-V0_3_5_WEBTRANSPORT_QUIC_STACK_AUDIT.md`. Le présent document porte les décisions opérationnelles, le scope, les gates et le forecast vivant de la version. Le cadrage détaillé et la comparaison des stacks actuelles sont conservés dans `docs/studies/026-V0_3_5_WEBTRANSPORT_QUIC_STACK_AUDIT.md`. Le présent document porte les décisions opérationnelles, le scope, les gates et le forecast vivant de la version.
@@ -57,6 +57,22 @@ La version exacte résolue par Cargo sera enregistrée dans le delta qui introdu
`wtransport 0.7.x` reste le candidat de repli prioritaire si une exigence concrète bloque la famille primaire. Quinn/H3 brut n'est pas le premier choix. `wtransport 0.7.x` reste le candidat de repli prioritaire si une exigence concrète bloque la famille primaire. Quinn/H3 brut n'est pas le premier choix.
### Fermeture de dépendances en alpha.2
La première tranche native utilise directement :
```text
web-transport-quinn 0.12.1, default-features = false, feature ring
rcgen 0.14.10, default-features = false, feature ring
url 2.5.8
```
La façade `web-transport` n'est pas ajoutée avant le chemin WASM : `alpha.2` ne possède qu'un backend natif et n'a pas besoin d'une abstraction multiplateforme encore inutilisée. `ring` est choisi explicitement et seul pour éviter le backend crypto par défaut `aws-lc-rs` de `web-transport-quinn` et garder le graphe POC plus petit et déterministe.
L'identité locale générée est ECDSA P-256/SHA-256, contient les SAN `localhost`, `127.0.0.1` et `::1`, vit sept jours avec une petite marge de clock skew et reste uniquement en mémoire. Une identité X.509 DER + PKCS#8 DER peut aussi être injectée. Le client accepte uniquement le hash SHA-256 explicitement configuré ; aucune API de désactivation de validation TLS n'est exposée.
`alpha.2` retourne une session WebTransport établie mais n'ouvre encore aucun stream applicatif et n'implémente pas `RealtimeConnection`. Cette séparation ferme la preuve QUIC/TLS avant le framing de `alpha.3`.
### Ownership physique ### Ownership physique
Nouveau backend durable candidat : Nouveau backend durable candidat :

39
history/0.3.5/alpha.1.md Normal file
View File

@@ -0,0 +1,39 @@
<!-- file: history/0.3.5/alpha.1.md -->
<!-- version: 1 -->
# Historique 0.3.5-alpha.1
## Statut
`0.3.5-alpha.1` a été validée par l'utilisateur le 2026-09-21. Le cadrage WebTransport/QUIC, le plan actif et la synchronisation de version sont acceptés sans fix.
La suite peut ouvrir `0.3.5-alpha.2` pour introduire le backend natif minimal et prouver l'établissement TLS/WebTransport avec pinning de certificat.
## Gates fournies
Les commandes exécutées par l'utilisateur :
```text
cargo fmt --all -- --check
python3 scripts/audit_rust_workspace_rules.py
python3 scripts/audit_markdown_tables.py README.md RULES.md ROADMAP.md CHANGELOG.md docs prompts crates Android Web deltas history
python3 scripts/audit_distribution_layout.py
cargo check --workspace
```
ont terminé proprement avec :
```text
General Rust rule audit: clean
Rust export completeness audit: 0 candidate(s)
games.sasedev workspace audit: clean
Markdown table audit: clean (5 table(s), 282 file(s))
Distribution layout audit: clean (50 required path(s), 8 forbidden path(s) absent)
cargo check --workspace: clean
```
Le workspace compilé porte `0.3.5-alpha.1`, y compris les deux POC Tauri affichés dans la sortie finale.
## Conséquence
Le forecast révisé de `docs/plans/005-V0_3_5_WEBTRANSPORT_QUIC_POC_PLAN.md` est retenu. `alpha.2` reste limitée à la crate backend, aux dépendances/features minimales, à l'identité TLS/hash pinning et à l'établissement natif ; streams fiables, framing et contrat commun restent réservés à `alpha.3`.