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};121314use 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;202122pub struct EnrollmentState {23 pending: Mutex<Option<PendingInvitation>>,24}252627struct PendingInvitation {28 token: String,29 invitation: SignedInvitation,30}31323334struct EnrollmentProfile {35 version: u16,36 host_node_id: String,37 peer_node_id: String,38 grants: u8,39 40 pending_invitation: Option<String>,41}424344pub struct InvitationSummary {45 host_node_id: String,46 peer_node_id: String,47 expires_at_unix: u64,48 grants: Vec<&'static str>,49}505152pub struct EnrollmentStatus {53 enrolled: bool,54 host_node_id: Option<String>,55 peer_node_id: Option<String>,56 grants: Vec<&'static str>,57 pending: Option<InvitationSummary>,58}596061pub struct ConnectionEnrollment {62 pub host_node_id: PublicKey,63 pub grants: InvitationGrants,64 pub pending_invitation: Option<String>,65}666768enum OpenedInvitationSource {69 DeepLinkToken(String),70 File(PathBuf),71}7273fn now_unix() -> Result<u64, String> {74 SystemTime::now()75 .duration_since(UNIX_EPOCH)76 .map(|duration| duration.as_secs())77 .map_err(|_| "System clock is before the Unix epoch".to_string())78}7980fn grant_names(grants: InvitationGrants) -> Vec<&'static str> {81 let mut names = Vec::with_capacity(3);82 if grants.contains(InvitationGrants::VIEW) {83 names.push("view");84 }85 if grants.contains(InvitationGrants::POINTER_KEYBOARD) {86 names.push("pointer_keyboard");87 }88 if grants.contains(InvitationGrants::GAMEPAD) {89 names.push("gamepad");90 }91 names92}9394fn public_key(bytes: [u8; 32], name: &str) -> Result<PublicKey, String> {95 PublicKey::from_bytes(&bytes).map_err(|error| format!("Invalid {name} node ID: {error}"))96}9798fn summary(invitation: &SignedInvitation) -> Result<InvitationSummary, String> {99 Ok(InvitationSummary {100 host_node_id: public_key(invitation.claims.host_node_id, "host")?.to_string(),101 peer_node_id: public_key(invitation.claims.intended_peer_id, "Portal")?.to_string(),102 expires_at_unix: invitation.claims.expires_at_unix,103 grants: grant_names(invitation.claims.grants),104 })105}106107fn validate_current(invitation: &SignedInvitation, now: u64) -> Result<(), String> {108 invitation109 .verify()110 .map_err(|error| format!("Invalid invitation signature: {error}"))?;111 if now < invitation.claims.issued_at_unix.saturating_sub(60) {112 return Err("Invitation was issued too far in the future".to_string());113 }114 if now > invitation.claims.expires_at_unix {115 return Err("Invitation has expired".to_string());116 }117 Ok(())118}119120fn decode_verified(token: String) -> Result<PendingInvitation, String> {121 if token.len() > MAX_INVITATION_TOKEN_LEN || token.trim() != token {122 return Err("Invitation is oversized or contains surrounding whitespace".to_string());123 }124 let invitation = SignedInvitation::decode(&token)125 .map_err(|error| format!("Malformed invitation: {error}"))?;126 Ok(PendingInvitation { token, invitation })127}128129fn decode_pending(token: String) -> Result<PendingInvitation, String> {130 let pending = decode_verified(token)?;131 validate_current(&pending.invitation, now_unix()?)?;132 Ok(pending)133}134135fn config_dir(app: &AppHandle) -> Result<PathBuf, String> {136 app.path()137 .app_config_dir()138 .map_err(|error| format!("Could not resolve Portal config directory: {error}"))139}140141fn profile_path(app: &AppHandle) -> Result<PathBuf, String> {142 Ok(config_dir(app)?.join(PROFILE_FILE))143}144145fn secure_config_dir(path: &Path) -> Result<(), String> {146 fs::create_dir_all(path)147 .map_err(|error| format!("Could not create Portal config directory: {error}"))?;148 let metadata = fs::symlink_metadata(path)149 .map_err(|error| format!("Could not inspect Portal config directory: {error}"))?;150 if !metadata.is_dir() || metadata.file_type().is_symlink() {151 return Err("Portal config path must be a real directory".to_string());152 }153 154 {155 if metadata.uid() != unsafe { libc::geteuid() } {156 return Err("Portal config directory is not owned by the current user".to_string());157 }158 fs::set_permissions(path, fs::Permissions::from_mode(0o700))159 .map_err(|error| format!("Could not secure Portal config directory: {error}"))?;160 }161 Ok(())162}163164fn read_bounded_file(path: &Path, maximum: u64, require_private: bool) -> Result<Vec<u8>, String> {165 let mut options = OpenOptions::new();166 options.read(true);167 168 options.custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC);169 let mut file = options170 .open(path)171 .map_err(|error| format!("Could not open {}: {error}", path.display()))?;172 let metadata = file173 .metadata()174 .map_err(|error| format!("Could not inspect {}: {error}", path.display()))?;175 if !metadata.is_file() || metadata.len() > maximum {176 return Err(format!("{} is not a bounded regular file", path.display()));177 }178 179 if require_private180 && (metadata.uid() != unsafe { libc::geteuid() } || metadata.mode() & 0o077 != 0)181 {182 return Err("Portal enrollment profile has unsafe ownership or permissions".to_string());183 }184 let mut bytes = Vec::with_capacity(metadata.len() as usize);185 file.read_to_end(&mut bytes)186 .map_err(|error| format!("Could not read {}: {error}", path.display()))?;187 Ok(bytes)188}189190fn invitation_token_from_file(path: &Path) -> Result<String, String> {191 if path.extension().and_then(|value| value.to_str()) != Some("goq-invite") {192 return Err("Invitation file must end in .goq-invite".to_string());193 }194 let bytes = read_bounded_file(path, MAX_IMPORT_BYTES, false)?;195 let token = std::str::from_utf8(&bytes)196 .map_err(|_| "Invitation file is not UTF-8".to_string())?197 .trim_end_matches(['\r', '\n']);198 if token.len() != bytes.len() && bytes.len() - token.len() > 2 {199 return Err("Invitation file has unexpected trailing data".to_string());200 }201 Ok(token.to_string())202}203204fn opened_invitation_source(url: &url::Url) -> Result<OpenedInvitationSource, String> {205 if url.scheme() == "goq" {206 if url.host_str() != Some("invite")207 || url.port().is_some()208 || !url.username().is_empty()209 || url.password().is_some()210 || url.query().is_some()211 || url.fragment().is_some()212 {213 return Err("Invitation deep link shape is invalid".to_string());214 }215 let mut segments = url216 .path_segments()217 .ok_or_else(|| "Invitation deep link has no token".to_string())?218 .filter(|segment| !segment.is_empty());219 let token = segments220 .next()221 .ok_or_else(|| "Invitation deep link has no token".to_string())?;222 if segments.next().is_some() {223 return Err("Invitation deep link has extra path segments".to_string());224 }225 Ok(OpenedInvitationSource::DeepLinkToken(token.to_string()))226 } else if url.scheme() == "file" {227 let path = url228 .to_file_path()229 .map_err(|_| "Invitation file URL is invalid".to_string())?;230 Ok(OpenedInvitationSource::File(path))231 } else {232 Err("Only goq invitation links and invitation files are accepted".to_string())233 }234}235236fn load_profile(path: &Path) -> Result<Option<EnrollmentProfile>, String> {237 match fs::symlink_metadata(path) {238 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),239 Err(error) => return Err(format!("Could not inspect enrollment profile: {error}")),240 Ok(metadata) if metadata.file_type().is_symlink() => {241 return Err("Enrollment profile must not be a symlink".to_string());242 }243 Ok(_) => {}244 }245 let bytes = read_bounded_file(path, MAX_PROFILE_BYTES, true)?;246 let profile: EnrollmentProfile = serde_json::from_slice(&bytes)247 .map_err(|error| format!("Enrollment profile is malformed: {error}"))?;248 validate_profile(&profile)?;249 Ok(Some(profile))250}251252fn validate_profile(profile: &EnrollmentProfile) -> Result<InvitationGrants, String> {253 if profile.version != PROFILE_VERSION {254 return Err("Enrollment profile version is unsupported".to_string());255 }256 profile257 .host_node_id258 .parse::<PublicKey>()259 .map_err(|error| format!("Enrollment host ID is invalid: {error}"))?;260 profile261 .peer_node_id262 .parse::<PublicKey>()263 .map_err(|error| format!("Enrollment peer ID is invalid: {error}"))?;264 let grants = InvitationGrants::new(profile.grants)265 .map_err(|error| format!("Enrollment grants are invalid: {error}"))?;266 if let Some(token) = &profile.pending_invitation {267 // A durable pending token may have expired after Sigil committed268 // enrollment but before Portal cleared it. Keep validating its exact269 // signed shape so the connection path can attempt the narrow,270 // ticket-free crash recovery as the same peer.271 let pending = decode_verified(token.clone())?;272 let invitation = &pending.invitation.claims;273 if public_key(invitation.host_node_id, "host")?.to_string() != profile.host_node_id274 || public_key(invitation.intended_peer_id, "Portal")?.to_string()275 != profile.peer_node_id276 || invitation.grants != grants277 {278 return Err("Pending invitation does not match its enrollment profile".to_string());279 }280 }281 Ok(grants)282}283284fn write_profile(path: &Path, profile: &EnrollmentProfile) -> Result<(), String> {285 validate_profile(profile)?;286 let parent = path287 .parent()288 .ok_or_else(|| "Enrollment profile has no parent directory".to_string())?;289 secure_config_dir(parent)?;290 if fs::symlink_metadata(path).is_ok_and(|metadata| metadata.file_type().is_symlink()) {291 return Err("Enrollment profile must not be a symlink".to_string());292 }293 let bytes = serde_json::to_vec(profile)294 .map_err(|error| format!("Could not encode enrollment profile: {error}"))?;295 if bytes.len() as u64 > MAX_PROFILE_BYTES {296 return Err("Enrollment profile exceeds its bounded size".to_string());297 }298 let mut random = [0_u8; 8];299 getrandom::fill(&mut random)300 .map_err(|error| format!("Could not name enrollment profile update: {error}"))?;301 let temporary = parent.join(format!(302 ".{PROFILE_FILE}.{:016x}.tmp",303 u64::from_be_bytes(random)304 ));305 let mut options = OpenOptions::new();306 options.write(true).create_new(true);307 308 options309 .mode(0o600)310 .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC);311 let result = (|| -> Result<(), String> {312 let mut file = options313 .open(&temporary)314 .map_err(|error| format!("Could not create enrollment profile: {error}"))?;315 file.write_all(&bytes)316 .map_err(|error| format!("Could not write enrollment profile: {error}"))?;317 file.sync_all()318 .map_err(|error| format!("Could not sync enrollment profile: {error}"))?;319 fs::rename(&temporary, path)320 .map_err(|error| format!("Could not activate enrollment profile: {error}"))?;321 322 std::fs::File::open(parent)323 .and_then(|directory| directory.sync_all())324 .map_err(|error| format!("Could not sync Portal config directory: {error}"))?;325 Ok(())326 })();327 if result.is_err() {328 let _ = fs::remove_file(&temporary);329 }330 result331}332333fn stage_token(state: &EnrollmentState, token: String) -> Result<InvitationSummary, String> {334 let pending = decode_pending(token)?;335 let result = summary(&pending.invitation)?;336 let mut slot = state337 .pending338 .lock()339 .map_err(|_| "Invitation staging state is unavailable".to_string())?;340 if slot.is_some() {341 return Err("Another invitation is already awaiting confirmation".to_string());342 }343 *slot = Some(pending);344 Ok(result)345}346347348pub fn portal_enrollment_status(349 app: AppHandle,350 state: State<'_, super::state::AppState>,351) -> Result<EnrollmentStatus, String> {352 let profile = load_profile(&profile_path(&app)?)?;353 let pending = state354 .enrollment355 .pending356 .lock()357 .map_err(|_| "Invitation staging state is unavailable".to_string())?358 .as_ref()359 .map(|pending| summary(&pending.invitation))360 .transpose()?;361 let Some(profile) = profile else {362 return Ok(EnrollmentStatus {363 enrolled: false,364 host_node_id: None,365 peer_node_id: None,366 grants: Vec::new(),367 pending,368 });369 };370 let grants = validate_profile(&profile)?;371 Ok(EnrollmentStatus {372 enrolled: true,373 host_node_id: Some(profile.host_node_id),374 peer_node_id: Some(profile.peer_node_id),375 grants: grant_names(grants),376 pending,377 })378}379380381pub fn portal_import_invitation_file(382 path: String,383 state: State<'_, super::state::AppState>,384) -> Result<InvitationSummary, String> {385 let path = PathBuf::from(path);386 stage_token(&state.enrollment, invitation_token_from_file(&path)?)387}388389fn ensure_profile_can_be_replaced(existing: Option<&EnrollmentProfile>) -> Result<(), String> {390 if existing.is_some_and(|profile| profile.pending_invitation.is_none()) {391 return Err(392 "Portal is already enrolled; revoke the Sigil enrollment and explicitly reset Portal before replacing it"393 .to_string(),394 );395 }396 Ok(())397}398399400pub async fn portal_confirm_invitation(401 app: AppHandle,402 state: State<'_, super::state::AppState>,403) -> Result<EnrollmentStatus, String> {404 // Connection setup holds this guard across profile snapshot, redemption,405 // and exact cleanup. Refuse to mutate durable enrollment concurrently.406 let _connection_serial = state.client_connection_serial.try_lock().map_err(|_| {407 "A Portal connection or enrollment update is already in progress".to_string()408 })?;409 let mut pending_slot = state410 .enrollment411 .pending412 .lock()413 .map_err(|_| "Invitation staging state is unavailable".to_string())?;414 let pending = pending_slot415 .as_ref()416 .cloned()417 .ok_or_else(|| "No invitation is awaiting confirmation".to_string())?;418 validate_current(&pending.invitation, now_unix()?)?;419 let path = profile_path(&app)?;420 let existing = load_profile(&path)?;421 ensure_profile_can_be_replaced(existing.as_ref())?;422 let claims = &pending.invitation.claims;423 let profile = EnrollmentProfile {424 version: PROFILE_VERSION,425 host_node_id: public_key(claims.host_node_id, "host")?.to_string(),426 peer_node_id: public_key(claims.intended_peer_id, "Portal")?.to_string(),427 grants: claims.grants.bits(),428 pending_invitation: Some(pending.token),429 };430 write_profile(&path, &profile)?;431 pending_slot.take();432 drop(pending_slot);433 drop(_connection_serial);434 portal_enrollment_status(app, state)435}436437438pub fn portal_cancel_invitation(state: State<'_, super::state::AppState>) -> Result<(), String> {439 state440 .enrollment441 .pending442 .lock()443 .map_err(|_| "Invitation staging state is unavailable".to_string())?444 .take();445 Ok(())446}447448449pub async fn portal_reset_enrollment(450 app: AppHandle,451 state: State<'_, super::state::AppState>,452 expected_host_node_id: String,453) -> Result<(), String> {454 let _connection_serial = state.client_connection_serial.try_lock().map_err(|_| {455 "A Portal connection or enrollment update is already in progress".to_string()456 })?;457 if state.client_connection_active.load(Ordering::SeqCst) {458 return Err("Disconnect before resetting Portal enrollment".to_string());459 }460 let path = profile_path(&app)?;461 let profile =462 load_profile(&path)?.ok_or_else(|| "Portal has no enrollment to reset".to_string())?;463 if expected_host_node_id != profile.host_node_id {464 return Err("Enrollment changed before reset confirmation".to_string());465 }466 fs::remove_file(&path)467 .map_err(|error| format!("Could not remove Portal enrollment: {error}"))?;468 469 if let Some(parent) = path.parent() {470 std::fs::File::open(parent)471 .and_then(|directory| directory.sync_all())472 .map_err(|error| format!("Could not sync Portal enrollment reset: {error}"))?;473 }474 state475 .enrollment476 .pending477 .lock()478 .map_err(|_| "Invitation staging state is unavailable".to_string())?479 .take();480 Ok(())481}482483pub fn stage_opened_url(app: &AppHandle, url: &url::Url) -> Result<(), String> {484 let state = app.state::<super::state::AppState>();485 let summary = match opened_invitation_source(url)? {486 OpenedInvitationSource::DeepLinkToken(token) => stage_token(&state.enrollment, token)?,487 OpenedInvitationSource::File(path) => {488 stage_token(&state.enrollment, invitation_token_from_file(&path)?)?489 }490 };491 app.emit("invitation-pending", summary)492 .map_err(|error| format!("Could not announce invitation: {error}"))?;493 if let Some(window) = app.get_webview_window("main") {494 let _ = window.show();495 let _ = window.set_focus();496 }497 Ok(())498}499500pub fn connection_enrollment(501 app: &AppHandle,502 actual_peer: PublicKey,503) -> Result<ConnectionEnrollment, String> {504 let profile = load_profile(&profile_path(app)?)?505 .ok_or_else(|| "Portal is not enrolled with a Sigil host".to_string())?;506 let grants = validate_profile(&profile)?;507 if profile.peer_node_id != actual_peer.to_string() {508 return Err(509 "Security key does not match the Portal peer named by the invitation".to_string(),510 );511 }512 Ok(ConnectionEnrollment {513 host_node_id: profile514 .host_node_id515 .parse()516 .map_err(|error| format!("Enrollment host ID is invalid: {error}"))?,517 grants,518 pending_invitation: profile.pending_invitation,519 })520}521522fn clear_exact_pending_invitation(523 profile: &mut EnrollmentProfile,524 expected_invitation: &str,525) -> Result<(), String> {526 match profile.pending_invitation.as_deref() {527 Some(actual) if actual == expected_invitation => {528 profile.pending_invitation = None;529 Ok(())530 }531 Some(_) => Err("Portal enrollment changed while its invitation was redeeming".to_string()),532 None => {533 Err("Portal invitation was already cleared before redemption completed".to_string())534 }535 }536}537538pub fn mark_invitation_redeemed(app: &AppHandle, expected_invitation: &str) -> Result<(), String> {539 let path = profile_path(app)?;540 let Some(mut profile) = load_profile(&path)? else {541 return Err("Portal enrollment disappeared during connection".to_string());542 };543 clear_exact_pending_invitation(&mut profile, expected_invitation)?;544 write_profile(&path, &profile)?;545 Ok(())546}547548549mod tests {550 use ed25519_dalek::SigningKey;551 use sigil_protocol::InvitationClaims;552553 use super::*;554555 fn token(now: u64) -> String {556 let host = SigningKey::from_bytes(&[7; 32]);557 let peer = SigningKey::from_bytes(&[9; 32]);558 let claims = InvitationClaims::new(559 host.verifying_key().to_bytes(),560 peer.verifying_key().to_bytes(),561 now - 1,562 now + 300,563 1,564 [3; 32],565 InvitationGrants::VIEW.union(InvitationGrants::GAMEPAD),566 )567 .unwrap();568 SignedInvitation::issue(claims, &[7; 32]).unwrap().encode()569 }570571 572 fn staged_summary_never_contains_the_raw_ticket() {573 let now = now_unix().unwrap();574 let state = EnrollmentState::default();575 let raw = token(now);576 let summary = stage_token(&state, raw.clone()).unwrap();577 let json = serde_json::to_string(&summary).unwrap();578 assert!(!json.contains(&raw));579 assert!(json.contains("gamepad"));580 }581582 583 fn profile_round_trip_is_private_and_bounded() {584 let temp = tempfile::tempdir().unwrap();585 let path = temp.path().join(PROFILE_FILE);586 587 fs::set_permissions(temp.path(), fs::Permissions::from_mode(0o700)).unwrap();588 let invitation = SignedInvitation::decode(&token(now_unix().unwrap())).unwrap();589 let profile = EnrollmentProfile {590 version: PROFILE_VERSION,591 host_node_id: public_key(invitation.claims.host_node_id, "host")592 .unwrap()593 .to_string(),594 peer_node_id: public_key(invitation.claims.intended_peer_id, "peer")595 .unwrap()596 .to_string(),597 grants: invitation.claims.grants.bits(),598 pending_invitation: Some(invitation.encode()),599 };600 write_profile(&path, &profile).unwrap();601 assert_eq!(load_profile(&path).unwrap().unwrap().grants, profile.grants);602 603 assert_eq!(fs::metadata(&path).unwrap().mode() & 0o777, 0o600);604 }605606 607 fn malformed_profile_and_second_pending_invitation_fail_closed() {608 let temp = tempfile::tempdir().unwrap();609 let path = temp.path().join(PROFILE_FILE);610 fs::write(&path, b"{} trailing").unwrap();611 612 fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).unwrap();613 assert!(load_profile(&path).is_err());614615 let state = EnrollmentState::default();616 let raw = token(now_unix().unwrap());617 stage_token(&state, raw.clone()).unwrap();618 assert!(stage_token(&state, raw).is_err());619 }620621 622 fn expired_durable_ticket_remains_available_for_ticket_free_recovery() {623 let invitation = SignedInvitation::decode(&token(1_000)).unwrap();624 let profile = EnrollmentProfile {625 version: PROFILE_VERSION,626 host_node_id: public_key(invitation.claims.host_node_id, "host")627 .unwrap()628 .to_string(),629 peer_node_id: public_key(invitation.claims.intended_peer_id, "peer")630 .unwrap()631 .to_string(),632 grants: invitation.claims.grants.bits(),633 pending_invitation: Some(invitation.encode()),634 };635 assert!(validate_profile(&profile).is_ok());636 assert!(decode_pending(profile.pending_invitation.unwrap()).is_err());637 }638639 640 fn redeemed_profile_requires_explicit_reset_before_replacement() {641 let invitation = SignedInvitation::decode(&token(now_unix().unwrap())).unwrap();642 let mut profile = EnrollmentProfile {643 version: PROFILE_VERSION,644 host_node_id: public_key(invitation.claims.host_node_id, "host")645 .unwrap()646 .to_string(),647 peer_node_id: public_key(invitation.claims.intended_peer_id, "peer")648 .unwrap()649 .to_string(),650 grants: invitation.claims.grants.bits(),651 pending_invitation: Some(invitation.encode()),652 };653 assert!(ensure_profile_can_be_replaced(Some(&profile)).is_ok());654 let expected = profile.pending_invitation.clone().unwrap();655 assert!(clear_exact_pending_invitation(&mut profile, "different-ticket").is_err());656 assert_eq!(657 profile.pending_invitation.as_deref(),658 Some(expected.as_str())659 );660 clear_exact_pending_invitation(&mut profile, &expected).unwrap();661 assert!(ensure_profile_can_be_replaced(Some(&profile)).is_err());662 }663664 665 fn strict_deep_link_shape_accepts_only_one_bounded_token_path() {666 let raw = token(now_unix().unwrap());667 let accepted = url::Url::parse(&format!("goq://invite/{raw}")).unwrap();668 assert_eq!(669 opened_invitation_source(&accepted).unwrap(),670 OpenedInvitationSource::DeepLinkToken(raw.clone())671 );672673 let rejected = [674 format!("goq://other/{raw}"),675 "goq://invite".to_string(),676 format!("goq://invite/{raw}/extra"),677 format!("goq://invite/{raw}?unexpected=1"),678 format!("goq://invite/{raw}#unexpected"),679 format!("goq://user@invite/{raw}"),680 format!("goq://invite:123/{raw}"),681 format!("https://invite/{raw}"),682 ];683 for value in rejected {684 let url = url::Url::parse(&value).unwrap();685 assert!(686 opened_invitation_source(&url).is_err(),687 "unexpected invitation URL accepted: {value}"688 );689 }690 }691692 693 fn file_ingress_accepts_only_bounded_exact_invitation_text() {694 let temp = tempfile::tempdir().unwrap();695 let raw = token(now_unix().unwrap());696 for (name, suffix) in [697 ("exact.goq-invite", ""),698 ("newline.goq-invite", "\n"),699 ("crlf.goq-invite", "\r\n"),700 ] {701 let path = temp.path().join(name);702 fs::write(&path, format!("{raw}{suffix}")).unwrap();703 assert_eq!(invitation_token_from_file(&path).unwrap(), raw);704 }705706 let file_url = url::Url::from_file_path(temp.path().join("exact.goq-invite")).unwrap();707 assert_eq!(708 opened_invitation_source(&file_url).unwrap(),709 OpenedInvitationSource::File(temp.path().join("exact.goq-invite"))710 );711 }712713 714 fn file_ingress_rejects_unsafe_or_malformed_sources() {715 let temp = tempfile::tempdir().unwrap();716 let raw = token(now_unix().unwrap());717718 let wrong_extension = temp.path().join("invitation.txt");719 fs::write(&wrong_extension, &raw).unwrap();720 assert!(invitation_token_from_file(&wrong_extension).is_err());721722 let trailing = temp.path().join("trailing.goq-invite");723 fs::write(&trailing, format!("{raw}\n\n\n")).unwrap();724 assert!(invitation_token_from_file(&trailing).is_err());725726 let invalid_utf8 = temp.path().join("invalid-utf8.goq-invite");727 fs::write(&invalid_utf8, [0xff, 0xfe]).unwrap();728 assert!(invitation_token_from_file(&invalid_utf8).is_err());729730 let oversized = temp.path().join("oversized.goq-invite");731 fs::write(&oversized, vec![b'x'; MAX_IMPORT_BYTES as usize + 1]).unwrap();732 assert!(invitation_token_from_file(&oversized).is_err());733734 let directory = temp.path().join("directory.goq-invite");735 fs::create_dir(&directory).unwrap();736 assert!(invitation_token_from_file(&directory).is_err());737738 739 {740 let target = temp.path().join("target.goq-invite");741 let symlink = temp.path().join("symlink.goq-invite");742 fs::write(&target, raw).unwrap();743 std::os::unix::fs::symlink(target, &symlink).unwrap();744 assert!(invitation_token_from_file(&symlink).is_err());745 }746 }747}