cat / goq
clone

turns a linux box into a private steam streaming appliance: plug in, tap your key, play from another computer

fix/docs-activation-truth
goq / src-tauri / src / commands / enrollment.rs
enrollment.rs 28.8K
1use std::fs::{self, OpenOptions};2use std::io::{Read, Write};3use std::path::{Path, PathBuf};4use std::sync::Mutex;5use std::sync::atomic::Ordering;6use std::time::{SystemTime, UNIX_EPOCH};78use iroh::PublicKey;9use serde::{Deserialize, Serialize};10use sigil_protocol::{InvitationGrants, MAX_INVITATION_TOKEN_LEN, SignedInvitation};11use tauri::{AppHandle, Emitter as _, Manager as _, State};1213#[cfg(unix)]14use std::os::unix::fs::{MetadataExt, OpenOptionsExt, PermissionsExt};1516const PROFILE_VERSION: u16 = 1;17const PROFILE_FILE: &str = "enrollment-v1.json";18const MAX_PROFILE_BYTES: u64 = 2_048;19const MAX_IMPORT_BYTES: u64 = (MAX_INVITATION_TOKEN_LEN + 1) as u64;2021#[derive(Debug, Default)]22pub struct EnrollmentState {23 pending: Mutex<Option<PendingInvitation>>,24}2526#[derive(Clone, Debug)]27struct PendingInvitation {28 token: String,29 invitation: SignedInvitation,30}3132#[derive(Clone, Debug, Deserialize, Serialize)]33#[serde(deny_unknown_fields)]34struct EnrollmentProfile {35 version: u16,36 host_node_id: String,37 peer_node_id: String,38 grants: u8,39 #[serde(default, skip_serializing_if = "Option::is_none")]40 pending_invitation: Option<String>,41}4243#[derive(Clone, Debug, Serialize)]44pub struct InvitationSummary {45 host_node_id: String,46 peer_node_id: String,47 expires_at_unix: u64,48 grants: Vec<&'static str>,49 view_only: bool,50 input_capable: bool,51}5253#[derive(Clone, Debug, Serialize)]54pub struct EnrollmentStatus {55 enrolled: bool,56 host_node_id: Option<String>,57 peer_node_id: Option<String>,58 grants: Vec<&'static str>,59 view_only: bool,60 input_capable: bool,61 pending: Option<InvitationSummary>,62}6364#[derive(Clone, Debug)]65pub struct ConnectionEnrollment {66 pub host_node_id: PublicKey,67 pub grants: InvitationGrants,68 pub pending_invitation: Option<String>,69}7071#[derive(Clone, Debug, PartialEq, Eq)]72enum OpenedInvitationSource {73 DeepLinkToken(String),74 File(PathBuf),75}7677fn now_unix() -> Result<u64, String> {78 SystemTime::now()79 .duration_since(UNIX_EPOCH)80 .map(|duration| duration.as_secs())81 .map_err(|_| "System clock is before the Unix epoch".to_string())82}8384fn grant_names(grants: InvitationGrants) -> Vec<&'static str> {85 let mut names = Vec::with_capacity(3);86 if grants.contains(InvitationGrants::VIEW) {87 names.push("view");88 }89 if grants.contains(InvitationGrants::POINTER_KEYBOARD) {90 names.push("pointer_keyboard");91 }92 if grants.contains(InvitationGrants::GAMEPAD) {93 names.push("gamepad");94 }95 names96}9798fn public_key(bytes: [u8; 32], name: &str) -> Result<PublicKey, String> {99 PublicKey::from_bytes(&bytes).map_err(|error| format!("Invalid {name} node ID: {error}"))100}101102fn summary(invitation: &SignedInvitation) -> Result<InvitationSummary, String> {103 let input_capable = invitation104 .claims105 .grants106 .contains(InvitationGrants::POINTER_KEYBOARD)107 || invitation.claims.grants.contains(InvitationGrants::GAMEPAD);108 Ok(InvitationSummary {109 host_node_id: public_key(invitation.claims.host_node_id, "host")?.to_string(),110 peer_node_id: public_key(invitation.claims.intended_peer_id, "Portal")?.to_string(),111 expires_at_unix: invitation.claims.expires_at_unix,112 grants: grant_names(invitation.claims.grants),113 view_only: !input_capable,114 input_capable,115 })116}117118fn validate_current(invitation: &SignedInvitation, now: u64) -> Result<(), String> {119 invitation120 .verify()121 .map_err(|error| format!("Invalid invitation signature: {error}"))?;122 if now < invitation.claims.issued_at_unix.saturating_sub(60) {123 return Err("Invitation was issued too far in the future".to_string());124 }125 if now > invitation.claims.expires_at_unix {126 return Err("Invitation has expired".to_string());127 }128 Ok(())129}130131fn decode_verified(token: String) -> Result<PendingInvitation, String> {132 if token.len() > MAX_INVITATION_TOKEN_LEN || token.trim() != token {133 return Err("Invitation is oversized or contains surrounding whitespace".to_string());134 }135 let invitation = SignedInvitation::decode(&token)136 .map_err(|error| format!("Malformed invitation: {error}"))?;137 Ok(PendingInvitation { token, invitation })138}139140fn decode_pending(token: String) -> Result<PendingInvitation, String> {141 let pending = decode_verified(token)?;142 validate_current(&pending.invitation, now_unix()?)?;143 Ok(pending)144}145146fn config_dir(app: &AppHandle) -> Result<PathBuf, String> {147 app.path()148 .app_config_dir()149 .map_err(|error| format!("Could not resolve Portal config directory: {error}"))150}151152fn profile_path(app: &AppHandle) -> Result<PathBuf, String> {153 Ok(config_dir(app)?.join(PROFILE_FILE))154}155156fn secure_config_dir(path: &Path) -> Result<(), String> {157 fs::create_dir_all(path)158 .map_err(|error| format!("Could not create Portal config directory: {error}"))?;159 let metadata = fs::symlink_metadata(path)160 .map_err(|error| format!("Could not inspect Portal config directory: {error}"))?;161 if !metadata.is_dir() || metadata.file_type().is_symlink() {162 return Err("Portal config path must be a real directory".to_string());163 }164 #[cfg(unix)]165 {166 if metadata.uid() != unsafe { libc::geteuid() } {167 return Err("Portal config directory is not owned by the current user".to_string());168 }169 fs::set_permissions(path, fs::Permissions::from_mode(0o700))170 .map_err(|error| format!("Could not secure Portal config directory: {error}"))?;171 }172 Ok(())173}174175fn read_bounded_file(path: &Path, maximum: u64, require_private: bool) -> Result<Vec<u8>, String> {176 let mut options = OpenOptions::new();177 options.read(true);178 #[cfg(unix)]179 options.custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC);180 let mut file = options181 .open(path)182 .map_err(|error| format!("Could not open {}: {error}", path.display()))?;183 let metadata = file184 .metadata()185 .map_err(|error| format!("Could not inspect {}: {error}", path.display()))?;186 if !metadata.is_file() || metadata.len() > maximum {187 return Err(format!("{} is not a bounded regular file", path.display()));188 }189 #[cfg(unix)]190 if require_private191 && (metadata.uid() != unsafe { libc::geteuid() } || metadata.mode() & 0o077 != 0)192 {193 return Err("Portal enrollment profile has unsafe ownership or permissions".to_string());194 }195 let mut bytes = Vec::with_capacity(metadata.len() as usize);196 file.read_to_end(&mut bytes)197 .map_err(|error| format!("Could not read {}: {error}", path.display()))?;198 Ok(bytes)199}200201fn invitation_token_from_file(path: &Path) -> Result<String, String> {202 if path.extension().and_then(|value| value.to_str()) != Some("goq-invite") {203 return Err("Invitation file must end in .goq-invite".to_string());204 }205 let bytes = read_bounded_file(path, MAX_IMPORT_BYTES, false)?;206 let token = std::str::from_utf8(&bytes)207 .map_err(|_| "Invitation file is not UTF-8".to_string())?208 .trim_end_matches(['\r', '\n']);209 if token.len() != bytes.len() && bytes.len() - token.len() > 2 {210 return Err("Invitation file has unexpected trailing data".to_string());211 }212 Ok(token.to_string())213}214215fn opened_invitation_source(url: &url::Url) -> Result<OpenedInvitationSource, String> {216 if url.scheme() == "goq" {217 if url.host_str() != Some("invite")218 || url.port().is_some()219 || !url.username().is_empty()220 || url.password().is_some()221 || url.query().is_some()222 || url.fragment().is_some()223 {224 return Err("Invitation deep link shape is invalid".to_string());225 }226 let mut segments = url227 .path_segments()228 .ok_or_else(|| "Invitation deep link has no token".to_string())?229 .filter(|segment| !segment.is_empty());230 let token = segments231 .next()232 .ok_or_else(|| "Invitation deep link has no token".to_string())?;233 if segments.next().is_some() {234 return Err("Invitation deep link has extra path segments".to_string());235 }236 Ok(OpenedInvitationSource::DeepLinkToken(token.to_string()))237 } else if url.scheme() == "file" {238 let path = url239 .to_file_path()240 .map_err(|_| "Invitation file URL is invalid".to_string())?;241 Ok(OpenedInvitationSource::File(path))242 } else {243 Err("Only goq invitation links and invitation files are accepted".to_string())244 }245}246247fn load_profile(path: &Path) -> Result<Option<EnrollmentProfile>, String> {248 match fs::symlink_metadata(path) {249 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),250 Err(error) => return Err(format!("Could not inspect enrollment profile: {error}")),251 Ok(metadata) if metadata.file_type().is_symlink() => {252 return Err("Enrollment profile must not be a symlink".to_string());253 }254 Ok(_) => {}255 }256 let bytes = read_bounded_file(path, MAX_PROFILE_BYTES, true)?;257 let profile: EnrollmentProfile = serde_json::from_slice(&bytes)258 .map_err(|error| format!("Enrollment profile is malformed: {error}"))?;259 validate_profile(&profile)?;260 Ok(Some(profile))261}262263fn validate_profile(profile: &EnrollmentProfile) -> Result<InvitationGrants, String> {264 if profile.version != PROFILE_VERSION {265 return Err("Enrollment profile version is unsupported".to_string());266 }267 profile268 .host_node_id269 .parse::<PublicKey>()270 .map_err(|error| format!("Enrollment host ID is invalid: {error}"))?;271 profile272 .peer_node_id273 .parse::<PublicKey>()274 .map_err(|error| format!("Enrollment peer ID is invalid: {error}"))?;275 let grants = InvitationGrants::new(profile.grants)276 .map_err(|error| format!("Enrollment grants are invalid: {error}"))?;277 if let Some(token) = &profile.pending_invitation {278 // A durable pending token may have expired after Sigil committed279 // enrollment but before Portal cleared it. Keep validating its exact280 // signed shape so the connection path can attempt the narrow,281 // ticket-free crash recovery as the same peer.282 let pending = decode_verified(token.clone())?;283 let invitation = &pending.invitation.claims;284 if public_key(invitation.host_node_id, "host")?.to_string() != profile.host_node_id285 || public_key(invitation.intended_peer_id, "Portal")?.to_string()286 != profile.peer_node_id287 || invitation.grants != grants288 {289 return Err("Pending invitation does not match its enrollment profile".to_string());290 }291 }292 Ok(grants)293}294295fn write_profile(path: &Path, profile: &EnrollmentProfile) -> Result<(), String> {296 validate_profile(profile)?;297 let parent = path298 .parent()299 .ok_or_else(|| "Enrollment profile has no parent directory".to_string())?;300 secure_config_dir(parent)?;301 if fs::symlink_metadata(path).is_ok_and(|metadata| metadata.file_type().is_symlink()) {302 return Err("Enrollment profile must not be a symlink".to_string());303 }304 let bytes = serde_json::to_vec(profile)305 .map_err(|error| format!("Could not encode enrollment profile: {error}"))?;306 if bytes.len() as u64 > MAX_PROFILE_BYTES {307 return Err("Enrollment profile exceeds its bounded size".to_string());308 }309 let mut random = [0_u8; 8];310 getrandom::fill(&mut random)311 .map_err(|error| format!("Could not name enrollment profile update: {error}"))?;312 let temporary = parent.join(format!(313 ".{PROFILE_FILE}.{:016x}.tmp",314 u64::from_be_bytes(random)315 ));316 let mut options = OpenOptions::new();317 options.write(true).create_new(true);318 #[cfg(unix)]319 options320 .mode(0o600)321 .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC);322 let result = (|| -> Result<(), String> {323 let mut file = options324 .open(&temporary)325 .map_err(|error| format!("Could not create enrollment profile: {error}"))?;326 file.write_all(&bytes)327 .map_err(|error| format!("Could not write enrollment profile: {error}"))?;328 file.sync_all()329 .map_err(|error| format!("Could not sync enrollment profile: {error}"))?;330 fs::rename(&temporary, path)331 .map_err(|error| format!("Could not activate enrollment profile: {error}"))?;332 #[cfg(unix)]333 std::fs::File::open(parent)334 .and_then(|directory| directory.sync_all())335 .map_err(|error| format!("Could not sync Portal config directory: {error}"))?;336 Ok(())337 })();338 if result.is_err() {339 let _ = fs::remove_file(&temporary);340 }341 result342}343344fn stage_token(state: &EnrollmentState, token: String) -> Result<InvitationSummary, String> {345 let pending = decode_pending(token)?;346 let result = summary(&pending.invitation)?;347 let mut slot = state348 .pending349 .lock()350 .map_err(|_| "Invitation staging state is unavailable".to_string())?;351 if slot.is_some() {352 return Err("Another invitation is already awaiting confirmation".to_string());353 }354 *slot = Some(pending);355 Ok(result)356}357358#[tauri::command]359pub fn portal_enrollment_status(360 app: AppHandle,361 state: State<'_, super::state::AppState>,362) -> Result<EnrollmentStatus, String> {363 let profile = load_profile(&profile_path(&app)?)?;364 let pending = state365 .enrollment366 .pending367 .lock()368 .map_err(|_| "Invitation staging state is unavailable".to_string())?369 .as_ref()370 .map(|pending| summary(&pending.invitation))371 .transpose()?;372 let Some(profile) = profile else {373 return Ok(EnrollmentStatus {374 enrolled: false,375 host_node_id: None,376 peer_node_id: None,377 grants: Vec::new(),378 view_only: false,379 input_capable: false,380 pending,381 });382 };383 let grants = validate_profile(&profile)?;384 let input_capable = grants.contains(InvitationGrants::POINTER_KEYBOARD)385 || grants.contains(InvitationGrants::GAMEPAD);386 Ok(EnrollmentStatus {387 enrolled: true,388 host_node_id: Some(profile.host_node_id),389 peer_node_id: Some(profile.peer_node_id),390 grants: grant_names(grants),391 view_only: !input_capable,392 input_capable,393 pending,394 })395}396397#[tauri::command]398pub fn portal_import_invitation_file(399 path: String,400 state: State<'_, super::state::AppState>,401) -> Result<InvitationSummary, String> {402 let path = PathBuf::from(path);403 stage_token(&state.enrollment, invitation_token_from_file(&path)?)404}405406fn ensure_profile_can_be_replaced(existing: Option<&EnrollmentProfile>) -> Result<(), String> {407 if existing.is_some_and(|profile| profile.pending_invitation.is_none()) {408 return Err(409 "Portal is already enrolled; revoke the Sigil enrollment and explicitly reset Portal before replacing it"410 .to_string(),411 );412 }413 Ok(())414}415416#[tauri::command]417pub async fn portal_confirm_invitation(418 app: AppHandle,419 state: State<'_, super::state::AppState>,420) -> Result<EnrollmentStatus, String> {421 // Connection setup holds this guard across profile snapshot, redemption,422 // and exact cleanup. Refuse to mutate durable enrollment concurrently.423 let _connection_serial = state.client_connection_serial.try_lock().map_err(|_| {424 "A Portal connection or enrollment update is already in progress".to_string()425 })?;426 let mut pending_slot = state427 .enrollment428 .pending429 .lock()430 .map_err(|_| "Invitation staging state is unavailable".to_string())?;431 let pending = pending_slot432 .as_ref()433 .cloned()434 .ok_or_else(|| "No invitation is awaiting confirmation".to_string())?;435 validate_current(&pending.invitation, now_unix()?)?;436 let path = profile_path(&app)?;437 let existing = load_profile(&path)?;438 ensure_profile_can_be_replaced(existing.as_ref())?;439 let claims = &pending.invitation.claims;440 let profile = EnrollmentProfile {441 version: PROFILE_VERSION,442 host_node_id: public_key(claims.host_node_id, "host")?.to_string(),443 peer_node_id: public_key(claims.intended_peer_id, "Portal")?.to_string(),444 grants: claims.grants.bits(),445 pending_invitation: Some(pending.token),446 };447 write_profile(&path, &profile)?;448 pending_slot.take();449 drop(pending_slot);450 drop(_connection_serial);451 portal_enrollment_status(app, state)452}453454#[tauri::command]455pub fn portal_cancel_invitation(state: State<'_, super::state::AppState>) -> Result<(), String> {456 state457 .enrollment458 .pending459 .lock()460 .map_err(|_| "Invitation staging state is unavailable".to_string())?461 .take();462 Ok(())463}464465#[tauri::command]466pub async fn portal_reset_enrollment(467 app: AppHandle,468 state: State<'_, super::state::AppState>,469 expected_host_node_id: String,470) -> Result<(), String> {471 let _connection_serial = state.client_connection_serial.try_lock().map_err(|_| {472 "A Portal connection or enrollment update is already in progress".to_string()473 })?;474 if state.client_connection_active.load(Ordering::SeqCst) {475 return Err("Disconnect before resetting Portal enrollment".to_string());476 }477 let path = profile_path(&app)?;478 let profile =479 load_profile(&path)?.ok_or_else(|| "Portal has no enrollment to reset".to_string())?;480 if expected_host_node_id != profile.host_node_id {481 return Err("Enrollment changed before reset confirmation".to_string());482 }483 fs::remove_file(&path)484 .map_err(|error| format!("Could not remove Portal enrollment: {error}"))?;485 #[cfg(unix)]486 if let Some(parent) = path.parent() {487 std::fs::File::open(parent)488 .and_then(|directory| directory.sync_all())489 .map_err(|error| format!("Could not sync Portal enrollment reset: {error}"))?;490 }491 state492 .enrollment493 .pending494 .lock()495 .map_err(|_| "Invitation staging state is unavailable".to_string())?496 .take();497 Ok(())498}499500pub fn stage_opened_url(app: &AppHandle, url: &url::Url) -> Result<(), String> {501 let state = app.state::<super::state::AppState>();502 let summary = match opened_invitation_source(url)? {503 OpenedInvitationSource::DeepLinkToken(token) => stage_token(&state.enrollment, token)?,504 OpenedInvitationSource::File(path) => {505 stage_token(&state.enrollment, invitation_token_from_file(&path)?)?506 }507 };508 app.emit("invitation-pending", summary)509 .map_err(|error| format!("Could not announce invitation: {error}"))?;510 if let Some(window) = app.get_webview_window("main") {511 let _ = window.show();512 let _ = window.set_focus();513 }514 Ok(())515}516517pub fn connection_enrollment(518 app: &AppHandle,519 actual_peer: PublicKey,520) -> Result<ConnectionEnrollment, String> {521 let profile = load_profile(&profile_path(app)?)?522 .ok_or_else(|| "Portal is not enrolled with a Sigil host".to_string())?;523 let grants = validate_profile(&profile)?;524 if profile.peer_node_id != actual_peer.to_string() {525 return Err(526 "Security key does not match the Portal peer named by the invitation".to_string(),527 );528 }529 Ok(ConnectionEnrollment {530 host_node_id: profile531 .host_node_id532 .parse()533 .map_err(|error| format!("Enrollment host ID is invalid: {error}"))?,534 grants,535 pending_invitation: profile.pending_invitation,536 })537}538539fn clear_exact_pending_invitation(540 profile: &mut EnrollmentProfile,541 expected_invitation: &str,542) -> Result<(), String> {543 match profile.pending_invitation.as_deref() {544 Some(actual) if actual == expected_invitation => {545 profile.pending_invitation = None;546 Ok(())547 }548 Some(_) => Err("Portal enrollment changed while its invitation was redeeming".to_string()),549 None => {550 Err("Portal invitation was already cleared before redemption completed".to_string())551 }552 }553}554555pub fn mark_invitation_redeemed(app: &AppHandle, expected_invitation: &str) -> Result<(), String> {556 let path = profile_path(app)?;557 let Some(mut profile) = load_profile(&path)? else {558 return Err("Portal enrollment disappeared during connection".to_string());559 };560 clear_exact_pending_invitation(&mut profile, expected_invitation)?;561 write_profile(&path, &profile)?;562 Ok(())563}564565#[cfg(test)]566mod tests {567 use ed25519_dalek::SigningKey;568 use sigil_protocol::InvitationClaims;569570 use super::*;571572 fn token_with_grants(now: u64, grants: InvitationGrants) -> String {573 let host = SigningKey::from_bytes(&[7; 32]);574 let peer = SigningKey::from_bytes(&[9; 32]);575 let claims = InvitationClaims::new(576 host.verifying_key().to_bytes(),577 peer.verifying_key().to_bytes(),578 now - 1,579 now + 300,580 1,581 [3; 32],582 grants,583 )584 .unwrap();585 SignedInvitation::issue(claims, &[7; 32]).unwrap().encode()586 }587588 fn token(now: u64) -> String {589 token_with_grants(now, InvitationGrants::VIEW.union(InvitationGrants::GAMEPAD))590 }591592 #[test]593 fn staged_summary_never_contains_the_raw_ticket() {594 let now = now_unix().unwrap();595 let state = EnrollmentState::default();596 let raw = token(now);597 let summary = stage_token(&state, raw.clone()).unwrap();598 let json = serde_json::to_string(&summary).unwrap();599 assert!(!json.contains(&raw));600 assert!(json.contains("gamepad"));601 assert!(json.contains("\"input_capable\":true"));602 assert!(json.contains("\"view_only\":false"));603 }604605 #[test]606 fn view_only_invitation_is_reported_without_input_eligibility() {607 let now = now_unix().unwrap();608 let state = EnrollmentState::default();609 let summary = stage_token(&state, token_with_grants(now, InvitationGrants::VIEW)).unwrap();610 let json = serde_json::to_string(&summary).unwrap();611 assert!(json.contains("\"view_only\":true"));612 assert!(json.contains("\"input_capable\":false"));613 }614615 #[test]616 fn profile_round_trip_is_private_and_bounded() {617 let temp = tempfile::tempdir().unwrap();618 let path = temp.path().join(PROFILE_FILE);619 #[cfg(unix)]620 fs::set_permissions(temp.path(), fs::Permissions::from_mode(0o700)).unwrap();621 let invitation = SignedInvitation::decode(&token(now_unix().unwrap())).unwrap();622 let profile = EnrollmentProfile {623 version: PROFILE_VERSION,624 host_node_id: public_key(invitation.claims.host_node_id, "host")625 .unwrap()626 .to_string(),627 peer_node_id: public_key(invitation.claims.intended_peer_id, "peer")628 .unwrap()629 .to_string(),630 grants: invitation.claims.grants.bits(),631 pending_invitation: Some(invitation.encode()),632 };633 write_profile(&path, &profile).unwrap();634 assert_eq!(load_profile(&path).unwrap().unwrap().grants, profile.grants);635 #[cfg(unix)]636 assert_eq!(fs::metadata(&path).unwrap().mode() & 0o777, 0o600);637 }638639 #[test]640 fn malformed_profile_and_second_pending_invitation_fail_closed() {641 let temp = tempfile::tempdir().unwrap();642 let path = temp.path().join(PROFILE_FILE);643 fs::write(&path, b"{} trailing").unwrap();644 #[cfg(unix)]645 fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).unwrap();646 assert!(load_profile(&path).is_err());647648 let state = EnrollmentState::default();649 let raw = token(now_unix().unwrap());650 stage_token(&state, raw.clone()).unwrap();651 assert!(stage_token(&state, raw).is_err());652 }653654 #[test]655 fn expired_durable_ticket_remains_available_for_ticket_free_recovery() {656 let invitation = SignedInvitation::decode(&token(1_000)).unwrap();657 let profile = EnrollmentProfile {658 version: PROFILE_VERSION,659 host_node_id: public_key(invitation.claims.host_node_id, "host")660 .unwrap()661 .to_string(),662 peer_node_id: public_key(invitation.claims.intended_peer_id, "peer")663 .unwrap()664 .to_string(),665 grants: invitation.claims.grants.bits(),666 pending_invitation: Some(invitation.encode()),667 };668 assert!(validate_profile(&profile).is_ok());669 assert!(decode_pending(profile.pending_invitation.unwrap()).is_err());670 }671672 #[test]673 fn redeemed_profile_requires_explicit_reset_before_replacement() {674 let invitation = SignedInvitation::decode(&token(now_unix().unwrap())).unwrap();675 let mut profile = EnrollmentProfile {676 version: PROFILE_VERSION,677 host_node_id: public_key(invitation.claims.host_node_id, "host")678 .unwrap()679 .to_string(),680 peer_node_id: public_key(invitation.claims.intended_peer_id, "peer")681 .unwrap()682 .to_string(),683 grants: invitation.claims.grants.bits(),684 pending_invitation: Some(invitation.encode()),685 };686 assert!(ensure_profile_can_be_replaced(Some(&profile)).is_ok());687 let expected = profile.pending_invitation.clone().unwrap();688 assert!(clear_exact_pending_invitation(&mut profile, "different-ticket").is_err());689 assert_eq!(690 profile.pending_invitation.as_deref(),691 Some(expected.as_str())692 );693 clear_exact_pending_invitation(&mut profile, &expected).unwrap();694 assert!(ensure_profile_can_be_replaced(Some(&profile)).is_err());695 }696697 #[test]698 fn strict_deep_link_shape_accepts_only_one_bounded_token_path() {699 let raw = token(now_unix().unwrap());700 let accepted = url::Url::parse(&format!("goq://invite/{raw}")).unwrap();701 assert_eq!(702 opened_invitation_source(&accepted).unwrap(),703 OpenedInvitationSource::DeepLinkToken(raw.clone())704 );705706 let rejected = [707 format!("goq://other/{raw}"),708 "goq://invite".to_string(),709 format!("goq://invite/{raw}/extra"),710 format!("goq://invite/{raw}?unexpected=1"),711 format!("goq://invite/{raw}#unexpected"),712 format!("goq://user@invite/{raw}"),713 format!("goq://invite:123/{raw}"),714 format!("https://invite/{raw}"),715 ];716 for value in rejected {717 let url = url::Url::parse(&value).unwrap();718 assert!(719 opened_invitation_source(&url).is_err(),720 "unexpected invitation URL accepted: {value}"721 );722 }723 }724725 #[test]726 fn file_ingress_accepts_only_bounded_exact_invitation_text() {727 let temp = tempfile::tempdir().unwrap();728 let raw = token(now_unix().unwrap());729 for (name, suffix) in [730 ("exact.goq-invite", ""),731 ("newline.goq-invite", "\n"),732 ("crlf.goq-invite", "\r\n"),733 ] {734 let path = temp.path().join(name);735 fs::write(&path, format!("{raw}{suffix}")).unwrap();736 assert_eq!(invitation_token_from_file(&path).unwrap(), raw);737 }738739 let file_url = url::Url::from_file_path(temp.path().join("exact.goq-invite")).unwrap();740 assert_eq!(741 opened_invitation_source(&file_url).unwrap(),742 OpenedInvitationSource::File(temp.path().join("exact.goq-invite"))743 );744 }745746 #[test]747 fn file_ingress_rejects_unsafe_or_malformed_sources() {748 let temp = tempfile::tempdir().unwrap();749 let raw = token(now_unix().unwrap());750751 let wrong_extension = temp.path().join("invitation.txt");752 fs::write(&wrong_extension, &raw).unwrap();753 assert!(invitation_token_from_file(&wrong_extension).is_err());754755 let trailing = temp.path().join("trailing.goq-invite");756 fs::write(&trailing, format!("{raw}\n\n\n")).unwrap();757 assert!(invitation_token_from_file(&trailing).is_err());758759 let invalid_utf8 = temp.path().join("invalid-utf8.goq-invite");760 fs::write(&invalid_utf8, [0xff, 0xfe]).unwrap();761 assert!(invitation_token_from_file(&invalid_utf8).is_err());762763 let oversized = temp.path().join("oversized.goq-invite");764 fs::write(&oversized, vec![b'x'; MAX_IMPORT_BYTES as usize + 1]).unwrap();765 assert!(invitation_token_from_file(&oversized).is_err());766767 let directory = temp.path().join("directory.goq-invite");768 fs::create_dir(&directory).unwrap();769 assert!(invitation_token_from_file(&directory).is_err());770771 #[cfg(unix)]772 {773 let target = temp.path().join("target.goq-invite");774 let symlink = temp.path().join("symlink.goq-invite");775 fs::write(&target, raw).unwrap();776 std::os::unix::fs::symlink(target, &symlink).unwrap();777 assert!(invitation_token_from_file(&symlink).is_err());778 }779 }780}