Skip to main content

foch_runner/
runtime.rs

1//! Bundle-independent runtime layers shared by player and test launches.
2//!
3//! Game asset directories are shared through links; Foch writes into owned
4//! runtime/state paths. Game and native plugin code retains the user's file
5//! permissions, including writes through those shared links.
6
7use crate::{layout, platform};
8use foch::plugin::store;
9use sha2::{Digest, Sha256};
10use std::{
11	fs, io,
12	path::{Path, PathBuf},
13};
14
15#[derive(Clone, Debug, serde::Serialize)]
16pub struct Excluded {
17	pub path: String,
18	pub sha256: Option<String>,
19}
20
21pub struct RuntimeLayer {
22	pub directory: PathBuf,
23	pub excluded: Vec<Excluded>,
24	pub fonts_sha256: String,
25	game_root: PathBuf,
26	longest_relative: usize,
27	remove_on_drop: bool,
28}
29
30pub use foch::plugin::deployment::ensure_outside_game;
31
32impl RuntimeLayer {
33	/// Create a fresh owned layer. Existing directories and locations inside
34	/// the source installation are refused, never adopted or overwritten.
35	pub fn prepare(game_root: &Path, runtime_base: &Path, run_id: &str) -> io::Result<Self> {
36		if run_id.is_empty()
37			|| !run_id
38				.bytes()
39				.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
40		{
41			return Err(io::Error::other("invalid runtime run id"));
42		}
43		let game_root = game_root.canonicalize()?;
44		ensure_outside_game(&game_root, runtime_base)?;
45		fs::create_dir_all(runtime_base)?;
46		let base = runtime_base.canonicalize()?;
47		let directory = PathBuf::from(foch::plugin::deployment::path_text(&base)?).join(run_id);
48		ensure_outside_game(&game_root, &base)?;
49		fs::create_dir(&directory)?;
50		let mut layer = Self {
51			directory,
52			excluded: Vec::new(),
53			fonts_sha256: String::new(),
54			game_root: game_root.clone(),
55			longest_relative: 0,
56			remove_on_drop: true,
57		};
58		create_marker(&layer.directory.join("foch-runtime"))?;
59		for entry in fs::read_dir(&game_root)? {
60			let entry = entry?;
61			let name = entry.file_name();
62			let name = name.to_string_lossy();
63			let is_dir = source_metadata(&entry.path())?.is_dir();
64			if name.eq_ignore_ascii_case("gfx") && is_dir {
65				layer.longest_relative = layer.longest_relative.max(deepest_relative(
66					&entry.path(),
67					name.encode_utf16().count(),
68				)?);
69				layer.prepare_gfx(&entry.path())?;
70				continue;
71			}
72			match layout::classify_entry(&name, is_dir) {
73				layout::LayerAction::Copy => {
74					layer.longest_relative =
75						layer.longest_relative.max(name.encode_utf16().count());
76					store::copy_package_file(&entry.path(), &layer.directory.join(&*name))?;
77				}
78				layout::LayerAction::Link => {
79					layer.longest_relative = layer.longest_relative.max(deepest_relative(
80						&entry.path(),
81						name.encode_utf16().count(),
82					)?);
83					platform::link_dir(&entry.path(), &layer.directory.join(&*name))?;
84				}
85				layout::LayerAction::Skip => layer.excluded.push(Excluded {
86					path: name.into_owned(),
87					sha256: if is_dir {
88						None
89					} else {
90						Some(format!(
91							"{:x}",
92							Sha256::digest(store::read_package_file(&entry.path())?)
93						))
94					},
95				}),
96			}
97		}
98		fs::create_dir_all(layer.directory.join("gfx/fonts/eu4-unicode"))?;
99		store::replace_package_file(&layer.directory.join("userdir.txt"), b"")?;
100		store::replace_package_file(&layer.directory.join("steam_appid.txt"), b"236850")?;
101		if let Some(error) = layer.budget_error() {
102			return Err(io::Error::other(error));
103		}
104		Ok(layer)
105	}
106
107	fn prepare_gfx(&mut self, source: &Path) -> io::Result<()> {
108		let destination = self.directory.join("gfx");
109		fs::create_dir(&destination)?;
110		for entry in fs::read_dir(source)? {
111			let entry = entry?;
112			let name = entry.file_name();
113			let metadata = source_metadata(&entry.path())?;
114			if name.to_string_lossy().eq_ignore_ascii_case("fonts") {
115				let fonts = destination.join("fonts");
116				fs::create_dir(&fonts)?;
117				let mut files = fs::read_dir(entry.path())?.collect::<io::Result<Vec<_>>>()?;
118				files.sort_by_key(|item| item.file_name());
119				let mut digest = Sha256::new();
120				for font in files {
121					if font
122						.file_name()
123						.to_string_lossy()
124						.eq_ignore_ascii_case("eu4-unicode")
125					{
126						continue;
127					}
128					if source_metadata(&font.path())?.is_dir() {
129						platform::link_dir(&font.path(), &fonts.join(font.file_name()))?;
130					} else {
131						let copied = fonts.join(font.file_name());
132						store::copy_package_file(&font.path(), &copied)?;
133						let bytes = store::read_package_file(&copied)?;
134						digest.update(font.file_name().to_string_lossy().as_bytes());
135						digest.update(Sha256::digest(&bytes));
136					}
137				}
138				self.fonts_sha256 = format!("{:x}", digest.finalize());
139			} else if metadata.is_dir() {
140				platform::link_dir(&entry.path(), &destination.join(name))?;
141			} else {
142				store::copy_package_file(&entry.path(), &destination.join(name))?;
143			}
144		}
145		Ok(())
146	}
147
148	pub fn redirect_unicode_cache(&self, cache: &Path) -> io::Result<()> {
149		ensure_outside_game(&self.game_root, cache)?;
150		fs::create_dir_all(cache)?;
151		let directory = self.directory.join("gfx/fonts/eu4-unicode");
152		fs::remove_dir(&directory)?;
153		platform::link_dir(&cache.canonicalize()?, &directory)
154	}
155
156	pub fn budget_error(&self) -> Option<String> {
157		(!layout::fits_path_budget(self.directory.to_string_lossy().encode_utf16().count(), self.longest_relative)).then(|| format!(
158			"runtime layer path {} is too long for the game's files (deepest relative {} chars); choose a shorter runtime base",
159			self.directory.display(), self.longest_relative
160		))
161	}
162
163	/// Player games keep running when the launching manager exits. Their layer
164	/// is retained with an ownership marker, and never swept as a test session.
165	pub fn retain(&mut self) -> io::Result<PathBuf> {
166		self.retain_after(|directory| Ok(directory.to_path_buf()))
167	}
168
169	/// Mark the layer before starting a player, then commit retention only when
170	/// startup and its bookkeeping succeed. On error the caller must stop any
171	/// child it started; dropping this layer then removes the failed preparation.
172	pub fn retain_after<T>(&mut self, start: impl FnOnce(&Path) -> io::Result<T>) -> io::Result<T> {
173		create_marker(&self.directory.join("foch-player-runtime"))?;
174		let result = start(&self.directory)?;
175		self.remove_on_drop = false;
176		Ok(result)
177	}
178}
179
180/// Publish a fresh marker through an exclusively created file handle.
181fn create_marker(path: &Path) -> io::Result<()> {
182	store::create_package_file(path, b"1\n")
183}
184
185/// Start an ordinary player game; dropping its Child handle does not stop it.
186pub fn spawn_player(
187	directory: &Path,
188	user_dir: &Path,
189	args: &[std::ffi::OsString],
190) -> io::Result<std::process::Child> {
191	std::process::Command::new(directory.join("eu4.exe"))
192		.current_dir(directory)
193		.arg(format!("-userdir={}", user_dir.display()))
194		.args(args)
195		.env("SteamAppId", "236850")
196		.env("SteamGameId", "236850")
197		.stdin(std::process::Stdio::null())
198		.stdout(std::process::Stdio::null())
199		.stderr(std::process::Stdio::null())
200		.spawn()
201}
202
203impl Drop for RuntimeLayer {
204	fn drop(&mut self) {
205		if self.remove_on_drop {
206			platform::remove_layer(&self.directory);
207		}
208	}
209}
210
211fn source_metadata(path: &Path) -> io::Result<fs::Metadata> {
212	let metadata = fs::symlink_metadata(path)?;
213	let linked = metadata.file_type().is_symlink();
214	#[cfg(windows)]
215	let linked = {
216		use std::os::windows::fs::MetadataExt;
217		linked || metadata.file_attributes() & 0x400 != 0
218	};
219	if linked {
220		return Err(io::Error::other(format!(
221			"source game entry cannot be linked: {}",
222			path.display()
223		)));
224	}
225	Ok(metadata)
226}
227
228fn deepest_relative(root: &Path, prefix: usize) -> io::Result<usize> {
229	let mut deepest = prefix;
230	for entry in fs::read_dir(root)? {
231		let entry = entry?;
232		let metadata = source_metadata(&entry.path())?;
233		let child = prefix + 1 + entry.file_name().to_string_lossy().encode_utf16().count();
234		deepest = deepest.max(if metadata.is_dir() {
235			deepest_relative(&entry.path(), child)?
236		} else {
237			child
238		});
239	}
240	Ok(deepest)
241}
242
243#[cfg(test)]
244mod tests {
245	use super::*;
246
247	#[test]
248	fn fresh_layers_exclude_runtime_metadata_and_preserve_source_entries() {
249		for is_dir in [false, true] {
250			let source = tempfile::tempdir().unwrap();
251			let output = tempfile::tempdir().unwrap();
252			let names = ["FOCH-RUNTIME", "Foch-Player-Runtime", "FOCH-HOST"];
253			for name in names {
254				let path = source.path().join(name);
255				if is_dir {
256					fs::create_dir(&path).unwrap();
257					fs::write(path.join("original"), "unchanged").unwrap();
258				} else {
259					fs::write(path, "unchanged").unwrap();
260				}
261			}
262			let layer = RuntimeLayer::prepare(source.path(), output.path(), "reserved").unwrap();
263			assert_eq!(
264				fs::read_to_string(layer.directory.join("foch-runtime")).unwrap(),
265				"1\n"
266			);
267			assert!(!layer.directory.join("foch-player-runtime").exists());
268			assert!(!layer.directory.join("foch-host").exists());
269			for name in names {
270				let excluded = layer
271					.excluded
272					.iter()
273					.find(|entry| entry.path == name)
274					.unwrap();
275				assert_eq!(excluded.sha256.is_none(), is_dir);
276				assert_eq!(
277					fs::read_to_string(if is_dir {
278						source.path().join(name).join("original")
279					} else {
280						source.path().join(name)
281					})
282					.unwrap(),
283					"unchanged"
284				);
285			}
286			let directory = layer.directory.clone();
287			crate::sweep_runtime_base(output.path(), std::time::Duration::ZERO);
288			assert!(
289				!directory.exists(),
290				"source player marker retained a test layer"
291			);
292			drop(layer);
293		}
294	}
295
296	#[test]
297	fn runtime_markers_refuse_existing_entries_without_truncating_external_files() {
298		let scratch = tempfile::tempdir().unwrap();
299		let original = scratch.path().join("original");
300		fs::write(&original, "unchanged").unwrap();
301		for name in ["foch-runtime", "foch-player-runtime"] {
302			let path = scratch.path().join(name);
303			fs::hard_link(&original, &path).unwrap();
304			assert_eq!(
305				create_marker(&path).unwrap_err().kind(),
306				io::ErrorKind::AlreadyExists
307			);
308			assert_eq!(fs::read_to_string(&original).unwrap(), "unchanged");
309			fs::remove_file(&path).unwrap();
310			fs::write(&path, "existing marker").unwrap();
311			assert_eq!(
312				create_marker(&path).unwrap_err().kind(),
313				io::ErrorKind::AlreadyExists
314			);
315			assert_eq!(fs::read_to_string(&path).unwrap(), "existing marker");
316			fs::remove_file(&path).unwrap();
317			create_marker(&path).unwrap();
318			assert_eq!(fs::read_to_string(&path).unwrap(), "1\n");
319		}
320		let source = tempfile::tempdir().unwrap();
321		let output = tempfile::tempdir().unwrap();
322		let mut layer = RuntimeLayer::prepare(source.path(), output.path(), "marker").unwrap();
323		let directory = layer.directory.clone();
324		fs::hard_link(&original, directory.join("foch-player-runtime")).unwrap();
325		let result: io::Result<()> =
326			layer.retain_after(|_| panic!("existing marker must prevent startup"));
327		assert_eq!(result.unwrap_err().kind(), io::ErrorKind::AlreadyExists);
328		drop(layer);
329		assert!(!directory.exists());
330		assert_eq!(fs::read_to_string(&original).unwrap(), "unchanged");
331	}
332
333	#[test]
334	fn source_directory_links_are_refused_without_traversal_or_source_changes() {
335		for relative in [
336			"assets/nested-link",
337			"gfx/fonts/nested-link",
338			"top-link",
339			"assets/loop",
340		] {
341			let source = tempfile::tempdir().unwrap();
342			let outside = tempfile::tempdir().unwrap();
343			let output = tempfile::tempdir().unwrap();
344			fs::create_dir_all(source.path().join("assets")).unwrap();
345			fs::create_dir_all(source.path().join("gfx/fonts")).unwrap();
346			fs::write(outside.path().join("original"), "untouched").unwrap();
347			let link = source.path().join(relative);
348			let target = if relative.ends_with("loop") {
349				source.path().join("assets")
350			} else {
351				outside.path().to_owned()
352			};
353			platform::link_dir(&target, &link).unwrap();
354			let result = RuntimeLayer::prepare(source.path(), output.path(), "linked-source");
355			platform::remove_layer(&link);
356			let error = match result {
357				Ok(layer) => {
358					drop(layer);
359					panic!("accepted source directory link {relative}")
360				}
361				Err(error) => error,
362			};
363			assert!(
364				error
365					.to_string()
366					.contains("source game entry cannot be linked"),
367				"{relative}: {error}"
368			);
369			assert!(!output.path().join("linked-source").exists());
370			assert_eq!(
371				fs::read(outside.path().join("original")).unwrap(),
372				b"untouched"
373			);
374		}
375	}
376
377	#[test]
378	fn non_ascii_game_directories_use_windows_path_units() {
379		let source = tempfile::tempdir().unwrap();
380		let output = tempfile::tempdir().unwrap();
381		fs::create_dir_all(source.path().join("目录😀/nested")).unwrap();
382		fs::write(source.path().join("目录😀/nested/file"), "resource").unwrap();
383		let layer = RuntimeLayer::prepare(source.path(), output.path(), "utf16").unwrap();
384		// Two BMP characters and one surrogate pair, plus /nested/file.
385		assert_eq!(layer.longest_relative, 4 + 12);
386		assert_eq!(
387			fs::read(layer.directory.join("目录😀/nested/file")).unwrap(),
388			b"resource"
389		);
390	}
391
392	#[test]
393	fn copied_files_obey_the_utf16_path_budget_and_failed_layers_are_removed() {
394		let source = tempfile::tempdir().unwrap();
395		let output = tempfile::tempdir().unwrap();
396		fs::create_dir(source.path().join("assets")).unwrap();
397		fs::write(source.path().join("assets/original"), "shared resource").unwrap();
398		let directory = PathBuf::from(
399			foch::plugin::deployment::path_text(&output.path().canonicalize().unwrap()).unwrap(),
400		)
401		.join("within");
402		let relative_len =
403			layout::MAX_PATH - directory.to_string_lossy().encode_utf16().count() - 2;
404		let filename = format!("{}😀.txt", "x".repeat(relative_len - 6));
405		fs::write(source.path().join(&filename), "copied resource").unwrap();
406		let layer = RuntimeLayer::prepare(source.path(), output.path(), "within").unwrap();
407		assert_eq!(
408			fs::read(layer.directory.join(&filename)).unwrap(),
409			b"copied resource"
410		);
411		assert!(layer.budget_error().is_none());
412		drop(layer);
413		assert!(!output.path().join("within").exists());
414
415		let too_long = format!("{filename}x");
416		fs::rename(source.path().join(&filename), source.path().join(&too_long)).unwrap();
417		let result = RuntimeLayer::prepare(source.path(), output.path(), "beyond");
418		let error = match result {
419			Ok(layer) => {
420				drop(layer);
421				panic!("accepted a copied file beyond the classic Windows path budget")
422			}
423			Err(error) => error,
424		};
425		assert!(error.to_string().contains("too long"), "{error}");
426		assert!(!output.path().join("beyond").exists());
427		assert_eq!(
428			fs::read(source.path().join(&too_long)).unwrap(),
429			b"copied resource"
430		);
431		assert_eq!(
432			fs::read(source.path().join("assets/original")).unwrap(),
433			b"shared resource"
434		);
435		fs::rename(source.path().join(&too_long), source.path().join(&filename)).unwrap();
436		let retry = RuntimeLayer::prepare(source.path(), output.path(), "beyond").unwrap();
437		assert_eq!(
438			fs::read(retry.directory.join(&filename)).unwrap(),
439			b"copied resource"
440		);
441		drop(retry);
442		assert!(!output.path().join("beyond").exists());
443	}
444
445	#[test]
446	fn layer_excludes_old_loaders_owns_font_writes_and_cleans_nested_links() {
447		let source = tempfile::tempdir().unwrap();
448		let output = tempfile::tempdir().unwrap();
449		fs::create_dir_all(source.path().join("gfx/fonts/eu4-unicode/cache")).unwrap();
450		fs::create_dir_all(source.path().join("gfx/interface")).unwrap();
451		fs::create_dir_all(source.path().join("plugins")).unwrap();
452		fs::write(source.path().join("eu4.exe"), "exe").unwrap();
453		fs::write(source.path().join("VERSION.dll"), "old loader").unwrap();
454		fs::write(source.path().join("gfx/fonts/base.fnt"), "original font").unwrap();
455		fs::write(source.path().join("gfx/interface/file"), "linked resource").unwrap();
456		fs::write(source.path().join("plugins/plugin64.dll"), "old patch").unwrap();
457		let layer = RuntimeLayer::prepare(source.path(), output.path(), "fixture").unwrap();
458		assert!(!layer.directory.join("VERSION.dll").exists());
459		assert!(!layer.directory.join("plugins").exists());
460		assert_eq!(
461			layer
462				.excluded
463				.iter()
464				.find(|item| item.path == "VERSION.dll")
465				.unwrap()
466				.sha256
467				.as_deref(),
468			Some("9aa2bd801c7163b8dc8a6668b43928b3ad16a19124f4e3e8dfb3e0db6ca607d8")
469		);
470		fs::write(layer.directory.join("gfx/fonts/base.fnt"), "new font").unwrap();
471		fs::write(
472			layer.directory.join("gfx/fonts/eu4-unicode/cache.txt"),
473			"new cache",
474		)
475		.unwrap();
476		assert_eq!(
477			fs::read_to_string(source.path().join("gfx/fonts/base.fnt")).unwrap(),
478			"original font"
479		);
480		drop(layer);
481		assert!(!output.path().join("fixture").exists());
482		assert_eq!(
483			fs::read_to_string(source.path().join("gfx/interface/file")).unwrap(),
484			"linked resource"
485		);
486		assert!(RuntimeLayer::prepare(source.path(), source.path(), "unsafe").is_err());
487	}
488
489	#[test]
490	fn copied_font_permissions_and_digest_match_the_source() {
491		let source = tempfile::tempdir().unwrap();
492		let output = tempfile::tempdir().unwrap();
493		fs::create_dir_all(source.path().join("gfx/fonts")).unwrap();
494		let font = source.path().join("gfx/fonts/base.fnt");
495		fs::write(&font, "original font").unwrap();
496		let original = fs::metadata(&font).unwrap().permissions();
497		#[cfg(unix)]
498		let permissions = {
499			use std::os::unix::fs::PermissionsExt;
500			fs::Permissions::from_mode(0o640)
501		};
502		#[cfg(windows)]
503		let permissions = {
504			let mut permissions = original.clone();
505			permissions.set_readonly(true);
506			permissions
507		};
508		fs::set_permissions(&font, permissions.clone()).unwrap();
509		let expected_permissions = fs::metadata(&font).unwrap().permissions();
510		let prepared = RuntimeLayer::prepare(source.path(), output.path(), "font-mode");
511		let layer = prepared.unwrap();
512		let copied = layer.directory.join("gfx/fonts/base.fnt");
513		let copied_permissions = fs::metadata(&copied).unwrap().permissions();
514		let copied_bytes = fs::read(&copied).unwrap();
515		let directory = layer.directory.clone();
516		let fonts_sha256 = layer.fonts_sha256.clone();
517		drop(layer);
518		let source_permissions = fs::metadata(&font).unwrap().permissions();
519		// Restore only the disposable source fixture for tempfile cleanup.
520		fs::set_permissions(&font, original.clone()).unwrap();
521		let remained = directory.exists();
522		if remained {
523			// Keep a failing cleanup regression from leaking this owned fixture.
524			fs::set_permissions(&copied, original).unwrap();
525			platform::remove_layer(&directory);
526		}
527		let mut digest = Sha256::new();
528		digest.update(b"base.fnt");
529		digest.update(Sha256::digest(b"original font"));
530		assert_eq!(fonts_sha256, format!("{:x}", digest.finalize()));
531		assert_eq!(copied_bytes, b"original font");
532		assert_eq!(fs::read(&font).unwrap(), b"original font");
533		assert_eq!(source_permissions, expected_permissions);
534		assert_eq!(copied_permissions, expected_permissions);
535		assert!(!remained, "dropping a layer left its read-only font behind");
536	}
537
538	#[test]
539	fn player_startup_is_marked_before_handoff_and_failed_starts_are_removed() {
540		let source = tempfile::tempdir().unwrap();
541		let output = tempfile::tempdir().unwrap();
542		let mut layer = RuntimeLayer::prepare(source.path(), output.path(), "handoff").unwrap();
543		let directory = layer.directory.clone();
544		let result: io::Result<()> = layer.retain_after(|directory| {
545			assert!(directory.join("foch-player-runtime").is_file());
546			crate::sweep_runtime_base(output.path(), std::time::Duration::ZERO);
547			assert!(directory.is_dir());
548			Err(io::Error::other("startup or PID bookkeeping failed"))
549		});
550		assert!(result.is_err());
551		drop(layer);
552		assert!(!directory.exists());
553
554		let mut layer = RuntimeLayer::prepare(source.path(), output.path(), "handoff").unwrap();
555		fs::create_dir(directory.join("foch-player-runtime")).unwrap();
556		let result: io::Result<()> =
557			layer.retain_after(|_| panic!("marker failure must prevent startup"));
558		assert!(result.is_err());
559		drop(layer);
560		assert!(!directory.exists());
561
562		let mut layer = RuntimeLayer::prepare(source.path(), output.path(), "handoff").unwrap();
563		layer.retain_after(|_| Ok(())).unwrap();
564		drop(layer);
565		crate::sweep_runtime_base(output.path(), std::time::Duration::ZERO);
566		assert!(directory.is_dir());
567		platform::remove_layer(&directory);
568	}
569
570	#[test]
571	fn retained_player_layers_survive_drop_and_test_sweeping() {
572		let source = tempfile::tempdir().unwrap();
573		let output = tempfile::tempdir().unwrap();
574		let mut layer = RuntimeLayer::prepare(source.path(), output.path(), "player").unwrap();
575		let directory = layer.retain().unwrap();
576		drop(layer);
577		crate::sweep_runtime_base(output.path(), std::time::Duration::ZERO);
578		assert!(directory.exists());
579		platform::remove_layer(&directory);
580		assert!(!directory.exists());
581		assert!(
582			RuntimeLayer::prepare(source.path(), &source.path().join("new-base"), "unsafe")
583				.is_err()
584		);
585		assert!(!source.path().join("new-base").exists());
586	}
587
588	#[test]
589	fn unicode_cache_redirect_handles_mixed_path_separators_without_touching_source() {
590		let source = tempfile::tempdir().unwrap();
591		let output = tempfile::tempdir().unwrap();
592		let cache = tempfile::tempdir().unwrap();
593		let layer = RuntimeLayer::prepare(source.path(), output.path(), "cache").unwrap();
594		layer.redirect_unicode_cache(cache.path()).unwrap();
595		fs::write(
596			layer.directory.join("gfx/fonts/eu4-unicode/generated"),
597			"cache",
598		)
599		.unwrap();
600		drop(layer);
601		assert_eq!(
602			fs::read_to_string(cache.path().join("generated")).unwrap(),
603			"cache"
604		);
605		assert!(!source.path().join("gfx").exists());
606	}
607
608	#[test]
609	fn links_handle_shell_metacharacters_as_literal_path_names() {
610		let root = tempfile::tempdir().unwrap();
611		let source = root.path().join("source&%FOCH_TEST_PATH%!");
612		let output = root.path().join("output&%FOCH_TEST_PATH%!");
613		fs::create_dir_all(source.join("gfx/interface")).unwrap();
614		fs::write(source.join("gfx/interface/file"), "original").unwrap();
615		let layer = RuntimeLayer::prepare(&source, &output, "literal").unwrap();
616		assert_eq!(
617			fs::read_to_string(layer.directory.join("gfx/interface/file")).unwrap(),
618			"original"
619		);
620		drop(layer);
621		assert!(!output.join("literal").exists());
622		assert_eq!(
623			fs::read_to_string(source.join("gfx/interface/file")).unwrap(),
624			"original"
625		);
626	}
627
628	#[test]
629	fn writable_roots_cannot_reach_the_game_through_a_link() {
630		let source = tempfile::tempdir().unwrap();
631		let output = tempfile::tempdir().unwrap();
632		let link = output.path().join("linked-game");
633		platform::link_dir(source.path(), &link).unwrap();
634		assert!(ensure_outside_game(source.path(), &link.join("new-state")).is_err());
635		assert!(!source.path().join("new-state").exists());
636		platform::remove_layer(&link);
637		assert!(source.path().exists());
638		// An unresolved link is an existing directory entry, not a missing
639		// component whose outside parent can authorize later writes.
640		let target = source.path().join("pending-target");
641		let moved = source.path().join("moved-target");
642		fs::create_dir(&target).unwrap();
643		fs::write(target.join("original"), "unchanged").unwrap();
644		let dangling = output.path().join("dangling");
645		platform::link_dir(&target, &dangling).unwrap();
646		fs::rename(&target, &moved).unwrap();
647		assert!(!dangling.exists());
648		assert!(fs::symlink_metadata(&dangling).is_ok());
649		for destination in [
650			dangling.clone(),
651			dangling.join("child"),
652			dangling.join("child/nested"),
653		] {
654			assert!(
655				ensure_outside_game(source.path(), &destination).is_err(),
656				"{}",
657				destination.display()
658			);
659		}
660		assert!(!target.exists());
661		assert_eq!(
662			fs::read_to_string(moved.join("original")).unwrap(),
663			"unchanged"
664		);
665		platform::remove_layer(&dangling);
666		let ordinary_missing = output.path().join("missing/nested");
667		ensure_outside_game(source.path(), &ordinary_missing).unwrap();
668		assert!(!output.path().join("missing").exists());
669		let sibling_source = output.path().join("game");
670		fs::create_dir(&sibling_source).unwrap();
671		assert!(
672			ensure_outside_game(
673				&sibling_source,
674				&output.path().join("missing/../game/profile")
675			)
676			.is_err()
677		);
678		assert!(!output.path().join("missing").exists());
679		assert!(!sibling_source.join("profile").exists());
680		// The data root can be outside while a nested cache points at the game.
681		let cache_link = output.path().join("cache");
682		platform::link_dir(source.path(), &cache_link).unwrap();
683		let layer = RuntimeLayer::prepare(source.path(), &output.path().join("rt"), "nested-cache")
684			.unwrap();
685		assert!(
686			layer
687				.redirect_unicode_cache(&cache_link.join("generated"))
688				.is_err()
689		);
690		assert!(!source.path().join("generated").exists());
691		platform::remove_layer(&cache_link);
692		// A user-selected base may alias an outside directory. Ownership begins
693		// at the fresh run directory, never at the base or an existing run.
694		let external = tempfile::tempdir().unwrap();
695		fs::write(external.path().join("original"), "unchanged").unwrap();
696		let base_alias = output.path().join("external-base");
697		platform::link_dir(external.path(), &base_alias).unwrap();
698		let owned = RuntimeLayer::prepare(source.path(), &base_alias, "owned-run").unwrap();
699		assert_eq!(
700			owned.directory.canonicalize().unwrap(),
701			external.path().join("owned-run").canonicalize().unwrap()
702		);
703		fs::write(owned.directory.join("sentinel"), "existing run").unwrap();
704		assert!(RuntimeLayer::prepare(source.path(), &base_alias, "owned-run").is_err());
705		assert_eq!(
706			fs::read_to_string(owned.directory.join("sentinel")).unwrap(),
707			"existing run"
708		);
709		drop(owned);
710		assert!(!external.path().join("owned-run").exists());
711		assert_eq!(
712			fs::read_to_string(external.path().join("original")).unwrap(),
713			"unchanged"
714		);
715		assert!(base_alias.exists());
716		platform::remove_layer(&base_alias);
717	}
718
719	#[test]
720	fn installed_store_directory_links_cannot_import_external_artifacts() {
721		use foch::plugin::{self, store::ArchiveEntry};
722		let outside = tempfile::tempdir().unwrap();
723		let manifest = plugin::Manifest::parse("schema=1\n[plugin]\nid=\"fixture\"\nname=\"Fixture\"\nversion=\"1.0.0\"\n[target]\ngame=\"eu4\"\nplatform=\"windows-x86_64\"\ngame_versions=\"*\"\nabi_major=1\n[entry]\nkind=\"native\"\nphase=\"entry\"\npath=\"fixture.dll\"").unwrap();
724		let mut dll = vec![0; 0x80];
725		dll[..2].copy_from_slice(b"MZ");
726		dll[0x3c..0x40].copy_from_slice(&0x40u32.to_le_bytes());
727		dll[0x40..0x44].copy_from_slice(b"PE\0\0");
728		dll[0x44..0x46].copy_from_slice(&plugin::store::MACHINE_AMD64.to_le_bytes());
729		let package = plugin::store::adapt(
730			manifest,
731			vec![ArchiveEntry {
732				path: "fixture.dll".into(),
733				data: dll.clone(),
734			}],
735		)
736		.unwrap();
737		let installed = match plugin::install(outside.path(), &package).unwrap() {
738			plugin::store::Installed::New(path) => path,
739			_ => unreachable!(),
740		};
741		let mut changed_dll = dll.clone();
742		changed_dll[0x7f] = 1;
743		let changed_package = plugin::store::adapt(
744			package.manifest.clone(),
745			vec![ArchiveEntry {
746				path: "fixture.dll".into(),
747				data: changed_dll,
748			}],
749		)
750		.unwrap();
751		for link_plugin_directory in [true, false] {
752			let store = tempfile::tempdir().unwrap();
753			let plugin_dir = store
754				.path()
755				.join(installed.parent().unwrap().file_name().unwrap());
756			let link = if link_plugin_directory {
757				platform::link_dir(installed.parent().unwrap(), &plugin_dir).unwrap();
758				plugin_dir
759			} else {
760				fs::create_dir(&plugin_dir).unwrap();
761				let link = plugin_dir.join(installed.file_name().unwrap());
762				platform::link_dir(&installed, &link).unwrap();
763				link
764			};
765			let changed_import =
766				link_plugin_directory.then(|| plugin::install(store.path(), &changed_package));
767			let repeated_import = plugin::install(store.path(), &package);
768			let (versions, problems) = plugin::store::installed_versions(store.path());
769			let deployment = plugin::deployment::resolve(
770				&plugin::GameIdentity {
771					game: "eu4".into(),
772					version: "1.37.5".parse().unwrap(),
773					platform: plugin::WINDOWS_X64.into(),
774				},
775				store.path(),
776				&[plugin::Selection {
777					id: "fixture".into(),
778					version: "1.0.0".parse().unwrap(),
779					enabled: true,
780					config: serde_json::json!({}),
781				}],
782			);
783			platform::remove_layer(&link);
784			if let Some(import) = changed_import {
785				assert!(
786					import.is_err(),
787					"import wrote through a plugin directory link"
788				);
789			}
790			assert!(
791				repeated_import.is_err(),
792				"linked version accepted as installed"
793			);
794			assert!(versions.is_empty(), "external artifact accepted");
795			assert!(!problems.is_empty());
796			assert!(deployment.is_err());
797			assert_eq!(fs::read(installed.join("fixture.dll")).unwrap(), dll);
798			assert_eq!(
799				fs::read_dir(installed.parent().unwrap()).unwrap().count(),
800				1
801			);
802		}
803	}
804
805	#[test]
806	fn derived_store_links_cannot_redirect_imports_into_source_directories() {
807		use foch::plugin::{self, store::ArchiveEntry};
808		let source = tempfile::tempdir().unwrap();
809		let output = tempfile::tempdir().unwrap();
810		let manifest = plugin::Manifest::parse("schema=1\n[plugin]\nid=\"fixture\"\nname=\"Fixture\"\nversion=\"1.0.0\"\n[target]\ngame=\"eu4\"\nplatform=\"windows-x86_64\"\ngame_versions=\"*\"\nabi_major=1\n[entry]\nkind=\"native\"\nphase=\"entry\"\npath=\"fixture.dll\"").unwrap();
811		let mut dll = vec![0; 0x80];
812		dll[..2].copy_from_slice(b"MZ");
813		dll[0x3c..0x40].copy_from_slice(&0x40u32.to_le_bytes());
814		dll[0x40..0x44].copy_from_slice(b"PE\0\0");
815		dll[0x44..0x46].copy_from_slice(&plugin::store::MACHINE_AMD64.to_le_bytes());
816		let package = plugin::store::adapt(
817			manifest,
818			vec![ArchiveEntry {
819				path: "fixture.dll".into(),
820				data: dll,
821			}],
822		)
823		.unwrap();
824		for (index, relative) in ["plugins", "plugins/store"].iter().enumerate() {
825			let data = output.path().join(format!("data-{index}"));
826			let link = data.join(relative);
827			fs::create_dir_all(link.parent().unwrap()).unwrap();
828			platform::link_dir(source.path(), &link).unwrap();
829			let result = plugin::paths::prepare_store_root(&data)
830				.and_then(|root| plugin::install(&root, &package));
831			platform::remove_layer(&link);
832			assert!(
833				result.is_err(),
834				"{relative} redirected an import into a source directory"
835			);
836			assert_eq!(fs::read_dir(source.path()).unwrap().count(), 0);
837		}
838		let alias_target = tempfile::tempdir().unwrap();
839		let data_alias = output.path().join("data-alias");
840		platform::link_dir(alias_target.path(), &data_alias).unwrap();
841		let store_root = plugin::paths::prepare_store_root(&data_alias).unwrap();
842		plugin::install(&store_root, &package).unwrap();
843		assert!(
844			store_root
845				.canonicalize()
846				.unwrap()
847				.starts_with(alias_target.path().canonicalize().unwrap())
848		);
849		assert_eq!(plugin::store::installed_versions(&store_root).0.len(), 1);
850		platform::remove_layer(&data_alias);
851	}
852
853	#[test]
854	fn derived_store_reads_refuse_internal_links_without_creating_missing_directories() {
855		use foch::plugin::{self, store::ArchiveEntry};
856		let outside = tempfile::tempdir().unwrap();
857		let output = tempfile::tempdir().unwrap();
858		let manifest = plugin::Manifest::parse("schema=1\n[plugin]\nid=\"fixture\"\nname=\"Fixture\"\nversion=\"1.0.0\"\n[target]\ngame=\"eu4\"\nplatform=\"windows-x86_64\"\ngame_versions=\"*\"\nabi_major=1\n[entry]\nkind=\"native\"\nphase=\"entry\"\npath=\"fixture.dll\"").unwrap();
859		let mut dll = vec![0; 0x80];
860		dll[..2].copy_from_slice(b"MZ");
861		dll[0x3c..0x40].copy_from_slice(&0x40u32.to_le_bytes());
862		dll[0x40..0x44].copy_from_slice(b"PE\0\0");
863		dll[0x44..0x46].copy_from_slice(&plugin::store::MACHINE_AMD64.to_le_bytes());
864		let package = plugin::store::adapt(
865			manifest,
866			vec![ArchiveEntry {
867				path: "fixture.dll".into(),
868				data: dll.clone(),
869			}],
870		)
871		.unwrap();
872		let real_store = plugin::paths::prepare_store_root(outside.path()).unwrap();
873		let installed = plugin::store::version_dir(&real_store, &package);
874		plugin::install(&real_store, &package).unwrap();
875		for (index, relative) in ["plugins", "plugins/store"].iter().enumerate() {
876			let data = output.path().join(format!("read-{index}"));
877			let link = data.join(relative);
878			fs::create_dir_all(link.parent().unwrap()).unwrap();
879			platform::link_dir(&outside.path().join(relative), &link).unwrap();
880			let result =
881				plugin::paths::checked_store_root(&data).map(|root| plugin::store::catalog(&root));
882			platform::remove_layer(&link);
883			assert!(result.is_err(), "{relative} read an external plugin store");
884			assert_eq!(fs::read(installed.join("fixture.dll")).unwrap(), dll);
885		}
886		for existing_root in [false, true] {
887			let data = output.path().join(format!("empty-{existing_root}"));
888			if existing_root {
889				fs::create_dir(&data).unwrap();
890			}
891			let root = plugin::paths::checked_store_root(&data).unwrap();
892			assert!(plugin::store::catalog(&root).0.is_empty());
893			assert_eq!(data.exists(), existing_root);
894			assert!(!data.join("plugins").exists());
895		}
896		let alias = output.path().join("selected-data-root");
897		platform::link_dir(outside.path(), &alias).unwrap();
898		let root = plugin::paths::checked_store_root(&alias).unwrap();
899		assert!(plugin::store::catalog(&root).0.contains_key("fixture"));
900		let entries_before = fs::read_dir(outside.path()).unwrap().count();
901		let missing = alias.join("missing-data");
902		let root = plugin::paths::checked_store_root(&missing).unwrap();
903		assert!(plugin::store::catalog(&root).0.is_empty());
904		assert!(!missing.exists());
905		assert_eq!(
906			fs::read_dir(outside.path()).unwrap().count(),
907			entries_before
908		);
909		assert_eq!(fs::read(installed.join("fixture.dll")).unwrap(), dll);
910		platform::remove_layer(&alias);
911	}
912
913	#[test]
914	fn deployment_refuses_a_nested_state_junction_before_writing_the_game() {
915		use foch::plugin::{self, store::ArchiveEntry};
916		let source = tempfile::tempdir().unwrap();
917		let output = tempfile::tempdir().unwrap();
918		let manifest = plugin::Manifest::parse("schema=1\n[plugin]\nid=\"fixture\"\nname=\"Fixture\"\nversion=\"1.0.0\"\n[target]\ngame=\"eu4\"\nplatform=\"windows-x86_64\"\ngame_versions=\"*\"\nabi_major=1\n[entry]\nkind=\"native\"\nphase=\"entry\"\npath=\"fixture.dll\"").unwrap();
919		let mut manifest = manifest;
920		manifest.writes.push(plugin::manifest::WriteKind::FontCache);
921		let mut dll = vec![0; 0x80];
922		dll[..2].copy_from_slice(b"MZ");
923		dll[0x3c..0x40].copy_from_slice(&0x40u32.to_le_bytes());
924		dll[0x40..0x44].copy_from_slice(b"PE\0\0");
925		dll[0x44..0x46].copy_from_slice(&plugin::store::MACHINE_AMD64.to_le_bytes());
926		let package = plugin::store::adapt(
927			manifest,
928			vec![ArchiveEntry {
929				path: "fixture.dll".into(),
930				data: dll,
931			}],
932		)
933		.unwrap();
934		let store = output.path().join("store");
935		plugin::install(&store, &package).unwrap();
936		let game = plugin::GameIdentity {
937			game: "eu4".into(),
938			version: "1.37.5".parse().unwrap(),
939			platform: plugin::WINDOWS_X64.into(),
940		};
941		let deployment = plugin::deployment::resolve(
942			&game,
943			&store,
944			&[plugin::Selection {
945				id: "fixture".into(),
946				version: "1.0.0".parse().unwrap(),
947				enabled: true,
948				config: serde_json::json!({}),
949			}],
950		)
951		.unwrap();
952		let layer =
953			RuntimeLayer::prepare(source.path(), &output.path().join("rt"), "state-junction")
954				.unwrap();
955		let data = output.path().join("data");
956		fs::create_dir_all(data.join("plugins")).unwrap();
957		let state = data.join("plugins/state");
958		platform::link_dir(source.path(), &state).unwrap();
959		assert!(
960			deployment
961				.stage(&layer.directory, &data, "state-junction", source.path())
962				.is_err()
963		);
964		assert_eq!(fs::read_dir(source.path()).unwrap().count(), 0);
965		platform::remove_layer(&state);
966		let outside = tempfile::tempdir().unwrap();
967		fs::write(outside.path().join("sentinel"), "unchanged").unwrap();
968		let state_leaf = format!(
969			"plugins/state/{}",
970			&plugin::deployment::hash(b"fixture")[..12]
971		);
972		let cache_leaf = format!("plugins/cache/{}", package.digest);
973		for (index, relative) in [
974			"plugins".into(),
975			"plugins/state".into(),
976			"plugins/cache".into(),
977			state_leaf,
978			cache_leaf,
979		]
980		.iter()
981		.enumerate()
982		{
983			let data = output.path().join(format!("data-{index}"));
984			let link = data.join(relative);
985			fs::create_dir_all(link.parent().unwrap()).unwrap();
986			platform::link_dir(outside.path(), &link).unwrap();
987			let run_id = format!("outside-data-{index}");
988			let layer =
989				RuntimeLayer::prepare(source.path(), &output.path().join("rt"), &run_id).unwrap();
990			let result = deployment.stage(&layer.directory, &data, &run_id, source.path());
991			platform::remove_layer(&link);
992			assert!(result.is_err(), "{relative} followed an outside link");
993			assert_eq!(fs::read_dir(outside.path()).unwrap().count(), 1);
994			assert_eq!(
995				fs::read_to_string(outside.path().join("sentinel")).unwrap(),
996				"unchanged"
997			);
998		}
999		let baseline = deployment
1000			.prepare_font_cache(
1001				&output.path().join("font-baseline"),
1002				"base-fonts",
1003				source.path(),
1004			)
1005			.unwrap()
1006			.unwrap();
1007		let font_leaf = Path::new("plugins/font-cache").join(baseline.file_name().unwrap());
1008		let font_leaf = font_leaf.to_string_lossy().into_owned();
1009		for (index, relative) in ["plugins".into(), "plugins/font-cache".into(), font_leaf]
1010			.iter()
1011			.enumerate()
1012		{
1013			let data = output.path().join(format!("font-data-{index}"));
1014			let link = data.join(relative);
1015			fs::create_dir_all(link.parent().unwrap()).unwrap();
1016			platform::link_dir(outside.path(), &link).unwrap();
1017			let result = deployment.prepare_font_cache(&data, "base-fonts", source.path());
1018			platform::remove_layer(&link);
1019			assert!(
1020				result.is_err(),
1021				"{relative} followed an outside font-cache link"
1022			);
1023			assert_eq!(fs::read_dir(outside.path()).unwrap().count(), 1);
1024		}
1025		// A selected root alias is supported, while its children remain owned.
1026		let aliased_root = output.path().join("data-alias");
1027		let alias_target = tempfile::tempdir().unwrap();
1028		platform::link_dir(alias_target.path(), &aliased_root).unwrap();
1029		let layer = RuntimeLayer::prepare(source.path(), &output.path().join("rt"), "aliased-data")
1030			.unwrap();
1031		// Host wire plans use Windows paths; directory/cache checks are portable.
1032		#[cfg(windows)]
1033		{
1034			let prepared = deployment
1035				.stage(
1036					&layer.directory,
1037					&aliased_root,
1038					"aliased-data",
1039					source.path(),
1040				)
1041				.unwrap();
1042			for kind in ["data", "cache"] {
1043				assert!(
1044					Path::new(&prepared.plan.plugins[0].dirs[kind])
1045						.canonicalize()
1046						.unwrap()
1047						.starts_with(alias_target.path().canonicalize().unwrap())
1048				);
1049			}
1050		}
1051		let font_cache = deployment
1052			.prepare_font_cache(&aliased_root, "base-fonts", source.path())
1053			.unwrap()
1054			.unwrap();
1055		assert!(font_cache.starts_with(alias_target.path().canonicalize().unwrap()));
1056		layer.redirect_unicode_cache(&font_cache).unwrap();
1057		fs::write(
1058			layer.directory.join("gfx/fonts/eu4-unicode/generated"),
1059			"font cache",
1060		)
1061		.unwrap();
1062		assert_eq!(
1063			fs::read_to_string(font_cache.join("generated")).unwrap(),
1064			"font cache"
1065		);
1066		assert_eq!(fs::read_dir(source.path()).unwrap().count(), 0);
1067		platform::remove_layer(&aliased_root);
1068		let marked_outside = output.path().join("marked-outside");
1069		fs::create_dir(&marked_outside).unwrap();
1070		fs::write(marked_outside.join("foch-runtime"), "fixture marker").unwrap();
1071		let runtime_link = output.path().join("runtime-link");
1072		platform::link_dir(&marked_outside, &runtime_link).unwrap();
1073		let result = deployment.stage(&runtime_link, &data, "linked-runtime", source.path());
1074		platform::remove_layer(&runtime_link);
1075		assert!(result.is_err(), "linked runtime accepted for staging");
1076		assert_eq!(fs::read_dir(&marked_outside).unwrap().count(), 1);
1077		fs::write(source.path().join("foch-runtime"), "fixture marker").unwrap();
1078		assert!(
1079			deployment
1080				.stage(source.path(), &data, "inside-game", source.path())
1081				.is_err()
1082		);
1083		platform::link_dir(source.path(), &runtime_link).unwrap();
1084		let result = deployment.stage(&runtime_link, &data, "linked-game", source.path());
1085		platform::remove_layer(&runtime_link);
1086		assert!(result.is_err());
1087		assert_eq!(fs::read_dir(source.path()).unwrap().count(), 1);
1088		assert_eq!(
1089			fs::read_to_string(source.path().join("foch-runtime")).unwrap(),
1090			"fixture marker"
1091		);
1092	}
1093}