1use 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 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 pub fn retain(&mut self) -> io::Result<PathBuf> {
166 self.retain_after(|directory| Ok(directory.to_path_buf()))
167 }
168
169 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
180fn create_marker(path: &Path) -> io::Result<()> {
182 store::create_package_file(path, b"1\n")
183}
184
185pub 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 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 fs::set_permissions(&font, original.clone()).unwrap();
521 let remained = directory.exists();
522 if remained {
523 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 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 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 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 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 #[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}