cat_dev/fsemul/
dlf.rs

1//! Code for creating/reading/managing Disk Layout Files.
2//!
3//! `.dlf` files, or Disk Layout Files as they're called in several places in
4//! the original cat-dev sources/documentations are layouts used in several
5//! places as a way of encoding addresses to files on the host filesystem.
6//!
7//! DLF files are not necissarily valid on any other host than the host they
8//! were created on. DLF Files are also guaranteed to have UTF-8 paths.
9
10use crate::{
11	errors::{CatBridgeError, FSError},
12	fsemul::errors::{FSEmulAPIError, FSEmulFSError},
13};
14use bytes::{Bytes, BytesMut};
15use std::{
16	collections::BTreeMap,
17	path::{Path, PathBuf},
18};
19use tokio::fs::metadata as get_path_metadata;
20use tracing::warn;
21
22/// The maximum address that can be stored in a DLF file.
23const MAX_ADDRESS: u128 = 0x000F_FFFF_FFFF_FFFF_FFFF_u128;
24
25/// A disk layout file.
26#[derive(Clone, Debug, PartialEq, Eq)]
27pub struct DiskLayoutFile {
28	/// A map of addresses to files on this host.
29	address_to_path_map: BTreeMap<u128, PathBuf>,
30	/// The current address that the layout file ends at.
31	current_ending_address: u128,
32	/// The major version of this disk layout file.
33	///
34	/// This assumes we're interpreting the version as
35	/// semver-"like" version where you have
36	/// `<major>.<minor>`.
37	major_version: u8,
38	/// The minor version of this disk layout file.
39	///
40	/// This assumes we're interpreting the version as
41	/// semver-"like" version where you have
42	/// `<major>.<minor>`.
43	minor_version: u8,
44}
45
46impl DiskLayoutFile {
47	/// Construct a new disk layout file with just an ending address.
48	#[must_use]
49	pub fn new(max_address: u128) -> Self {
50		let mut map = BTreeMap::new();
51		map.insert(max_address, PathBuf::new());
52
53		Self {
54			address_to_path_map: map,
55			current_ending_address: max_address,
56			major_version: 1,
57			minor_version: 0,
58		}
59	}
60
61	/// Get the version string that would appear in the DLF file.
62	#[must_use]
63	pub fn version(&self) -> String {
64		format!("v{}.{:02}", self.major_version, self.minor_version)
65	}
66
67	/// Get the major version of this disk layout file.
68	#[must_use]
69	pub const fn major_version(&self) -> u8 {
70		self.major_version
71	}
72
73	/// Get the minor version of this disk layout file.
74	#[must_use]
75	pub const fn minor_version(&self) -> u8 {
76		self.minor_version
77	}
78
79	/// Get the max address on this DLF.
80	#[must_use]
81	pub const fn max_address(&self) -> u128 {
82		self.current_ending_address
83	}
84
85	/// Get the current mapping of addresses to paths on the host filesystem.
86	#[must_use]
87	pub fn address_to_path_map(&self) -> &BTreeMap<u128, PathBuf> {
88		&self.address_to_path_map
89	}
90
91	/// Get the path at a particular address.
92	#[must_use]
93	pub async fn get_path_and_offset_for_file(
94		&self,
95		requested_address: u128,
96	) -> Option<(&PathBuf, u64)> {
97		// Attempt to find an exact match.
98		if let Some(path) = self.address_to_path_map.get(&requested_address) {
99			return Some((path, 0));
100		}
101
102		// Otherwise try to find an address that might be in the middle of a file.
103		let mut last_addr = 0_u128;
104		let mut last_path = self
105			.address_to_path_map
106			.get(&self.max_address())
107			.unwrap_or_else(|| unreachable!());
108		for (addr, path) in &self.address_to_path_map {
109			if *addr < requested_address {
110				last_addr = *addr;
111				last_path = path;
112				continue;
113			}
114			let metadata = match get_path_metadata(last_path).await {
115				Ok(md) => md,
116				Err(cause) => {
117					warn!(
118						?cause,
119						path = %last_path.display(),
120						"Failed to get metadata for path, not sure if matching over SDIO, treating as non-match.",
121					);
122					break;
123				}
124			};
125			let offset = u64::try_from(requested_address - last_addr).unwrap_or(u64::MAX);
126			// Whee! We did find a match, and it's right in the middle of another
127			// file.
128			if metadata.len() > offset {
129				return Some((last_path, offset));
130			}
131
132			// Break if we can't be in the middle of a file.
133			break;
134		}
135
136		// No match found.
137		None
138	}
139
140	/// Insert, or update the path at a particular address.
141	///
142	/// ## Errors
143	///
144	/// - If you try to write an address past [`MAX_ADDRESS`].
145	/// - If you try inserting an address past the ending of PATH.
146	/// - If we cannot canonicalize the path you past in.
147	pub fn upsert_addressed_path(
148		&mut self,
149		address: u128,
150		path: &Path,
151	) -> Result<(), CatBridgeError> {
152		if address >= MAX_ADDRESS {
153			return Err(FSEmulAPIError::DlfAddressTooLarge(address, MAX_ADDRESS).into());
154		}
155
156		let mut update_ending_address = false;
157		// If we're trying to write past the end, and not just update the end.
158		if address > self.current_ending_address {
159			if !path.as_os_str().is_empty() {
160				return Err(FSEmulAPIError::DlfUpsertEndingFirst.into());
161			}
162
163			update_ending_address = true;
164		}
165
166		let canonicalized_path = path.canonicalize().map_err(FSError::from)?;
167		// Validate our canonicalized path is UTF-8.
168		let Ok(_) = canonicalized_path.as_os_str().to_owned().into_string() else {
169			return Err(FSEmulAPIError::DlfPathMustBeUtf8(Bytes::from(Vec::from(
170				canonicalized_path.as_os_str().to_owned().as_encoded_bytes(),
171			)))
172			.into());
173		};
174		self.address_to_path_map.insert(address, canonicalized_path);
175
176		if update_ending_address {
177			self.current_ending_address = address;
178		}
179
180		Ok(())
181	}
182
183	/// Remove a path at a particular address.
184	///
185	/// ## Errors
186	///
187	/// If you try to remove the ending address for the DLF file. DLF files do
188	/// require an ending.
189	pub fn remove_path_at_address(&mut self, address: u128) -> Result<(), FSEmulAPIError> {
190		if address == self.current_ending_address {
191			return Err(FSEmulAPIError::DlfMustHaveEnding);
192		}
193		self.address_to_path_map.remove(&address);
194
195		Ok(())
196	}
197}
198
199impl From<&DiskLayoutFile> for Bytes {
200	fn from(value: &DiskLayoutFile) -> Self {
201		let mut bytes = BytesMut::new();
202		bytes.extend_from_slice(value.version().as_bytes());
203		bytes.extend_from_slice(b"\r\n");
204		for (address, path) in &value.address_to_path_map {
205			bytes.extend_from_slice(
206				format!(
207					"0x{address:016X},\"{}\"\r\n",
208					// We already know it's utf-8, so we know this is safe.
209					path.to_string_lossy(),
210				)
211				.as_bytes(),
212			);
213		}
214
215		bytes.freeze()
216	}
217}
218
219impl From<DiskLayoutFile> for Bytes {
220	fn from(value: DiskLayoutFile) -> Self {
221		Self::from(&value)
222	}
223}
224
225impl TryFrom<Bytes> for DiskLayoutFile {
226	type Error = FSError;
227
228	fn try_from(value: Bytes) -> Result<Self, Self::Error> {
229		let as_utf8 = String::from_utf8(value.to_vec())?;
230		let lines = as_utf8.split("\r\n").collect::<Vec<_>>();
231		// We expect at least 3 lines!
232		//
233		// ```text
234		// <version line>
235		// <end of dlf>
236		//
237		// ```
238		//
239		// (that last blank line is just because files are guaranteed to end with
240		// `\r\n`).
241		if lines.len() < 3 {
242			return Err(FSError::TooFewLines(lines.len(), 3_usize));
243		}
244
245		let mut address_map = BTreeMap::new();
246		// For each line that maps to an address.
247		//
248		// EXCEPT the final line which should always be our empty address.
249		let mut last_read_address: u128 = 0;
250		for line in &lines[1..lines.len() - 2] {
251			let mut iterator = line.splitn(2, ',');
252			let Some(address_str) = iterator.next() else {
253				return Err(FSEmulFSError::DlfCorruptLine((*line).to_owned()).into());
254			};
255			let Some(path_string) = iterator.next() else {
256				return Err(FSEmulFSError::DlfCorruptLine((*line).to_owned()).into());
257			};
258
259			// Parse out an address, it should be in increasing order...
260			let address = u128::from_str_radix(address_str.trim_start_matches("0x"), 16)
261				.map_err(|_| FSEmulFSError::DlfCorruptLine((*line).to_owned()))?;
262			// For the first address they can be equal... for anything past the start
263			// we can't have two files in the same place.
264			if last_read_address != 0 && address <= last_read_address {
265				return Err(FSEmulFSError::DlfCorruptLine((*line).to_owned()).into());
266			}
267			last_read_address = address;
268
269			// Parse out the path! We don't necissarily need to check it exists,
270			// just that it's absolute...
271			let path = PathBuf::from(path_string.trim_matches('"'));
272			// Empty path aka the end is only available at the last line
273			// which we explicitly exclude from the loop.
274			if path.as_os_str().is_empty() {
275				return Err(FSEmulFSError::DlfCorruptLine((*line).to_owned()).into());
276			}
277			if !path.is_absolute() {
278				return Err(FSEmulFSError::DlfCorruptLine((*line).to_owned()).into());
279			}
280			address_map.insert(address, path);
281		}
282
283		let should_be_ending_line = lines[lines.len() - 2];
284		let mut ending_iter = should_be_ending_line.splitn(2, ',');
285		let Some(final_address_str) = ending_iter.next() else {
286			return Err(FSEmulFSError::DlfCorruptLine(should_be_ending_line.to_owned()).into());
287		};
288		let Some(final_path_str) = ending_iter.next() else {
289			return Err(FSEmulFSError::DlfCorruptLine(should_be_ending_line.to_owned()).into());
290		};
291		let final_address = u128::from_str_radix(final_address_str.trim_start_matches("0x"), 16)
292			.map_err(|_| FSEmulFSError::DlfCorruptLine(should_be_ending_line.to_owned()))?;
293
294		if last_read_address != 0 && final_address <= last_read_address {
295			return Err(FSEmulFSError::DlfCorruptLine(should_be_ending_line.to_owned()).into());
296		}
297		if final_path_str != r#""""# {
298			return Err(FSEmulFSError::DlfCorruptLine(should_be_ending_line.to_owned()).into());
299		}
300		address_map.insert(final_address, PathBuf::new());
301		if !lines[lines.len() - 1].is_empty() {
302			return Err(
303				FSEmulFSError::DlfCorruptFinalLine(lines[lines.len() - 1].to_owned()).into(),
304			);
305		}
306
307		let version_str = lines[0];
308		if !version_str.starts_with('v') {
309			return Err(FSEmulFSError::DlfCorruptVersionLine(version_str.to_owned()).into());
310		}
311		let mut version_iter = version_str.trim_start_matches('v').splitn(2, '.');
312		let Some(major_version_str) = version_iter.next() else {
313			return Err(FSEmulFSError::DlfCorruptVersionLine(version_str.to_owned()).into());
314		};
315		let Some(minor_version_str) = version_iter.next() else {
316			return Err(FSEmulFSError::DlfCorruptVersionLine(version_str.to_owned()).into());
317		};
318		let Ok(major_version) = major_version_str.parse::<u8>() else {
319			return Err(FSEmulFSError::DlfCorruptVersionLine(version_str.to_owned()).into());
320		};
321		let Ok(minor_version) = minor_version_str.parse::<u8>() else {
322			return Err(FSEmulFSError::DlfCorruptVersionLine(version_str.to_owned()).into());
323		};
324
325		Ok(Self {
326			address_to_path_map: address_map,
327			current_ending_address: final_address,
328			major_version,
329			minor_version,
330		})
331	}
332}
333
334#[cfg(test)]
335mod unit_tests {
336	use super::*;
337
338	#[must_use]
339	pub fn get_test_data_path(relative_to_test_data: &str) -> PathBuf {
340		let mut final_path = PathBuf::from(
341			std::env::var("CARGO_MANIFEST_DIR")
342				.expect("Failed to read `CARGO_MANIFEST_DIR` to locate test files!"),
343		);
344		final_path.push("src");
345		final_path.push("fsemul");
346		final_path.push("test-data");
347		for file_part in relative_to_test_data.split('/') {
348			if file_part.is_empty() {
349				continue;
350			}
351			final_path.push(file_part);
352		}
353		final_path
354	}
355
356	#[tokio::test]
357	pub async fn can_parse_real_files() {
358		// Just validate these don't error.
359		let real_life_dlf;
360
361		#[cfg(target_os = "windows")]
362		{
363			real_life_dlf = Bytes::from(
364				std::fs::read(get_test_data_path("ppc_boot_win.dlf"))
365					.expect("Failed to read `ppc_boot.dlf` test data file!"),
366			);
367		}
368		#[cfg(not(target_os = "windows"))]
369		{
370			real_life_dlf = Bytes::from(
371				std::fs::read(get_test_data_path("ppc_boot.dlf"))
372					.expect("Failed to read `ppc_boot.dlf` test data file!"),
373			);
374		}
375
376		let empty_dlf = Bytes::from(
377			std::fs::read(get_test_data_path("minimal.dlf"))
378				.expect("Failed to read `minimal.dlf` test data file!"),
379		);
380
381		let dlf = DiskLayoutFile::try_from(real_life_dlf.clone())
382			.expect("Failed to parse real life dlf file!");
383		let edlf = DiskLayoutFile::try_from(empty_dlf.clone())
384			.expect("Failed to parse the most minimal of disk layout files!");
385
386		assert_eq!(
387			dlf.major_version(),
388			1,
389			"Real-DLF didnt parse correct major version!"
390		);
391		assert_eq!(
392			dlf.minor_version(),
393			0,
394			"Real-DLF didn't parse correct minor version!"
395		);
396		#[cfg(target_os = "windows")]
397		assert_eq!(
398			dlf.get_path_and_offset_for_file(0x80000_u128).await,
399			Some((
400				&PathBuf::from(r#"C:\cafe_sdk\temp\mythra\caferun\ppc.bsf"#),
401				0
402			)),
403			"Real-DLF did not match correct path for address.",
404		);
405		#[cfg(not(target_os = "windows"))]
406		assert_eq!(
407			dlf.get_path_and_offset_for_file(0x80000_u128).await,
408			Some((
409				&PathBuf::from(r#"/opt/cafe_sdk/temp/mythra/caferun/ppc.bsf"#),
410				0
411			)),
412			"Real-DLF did not match correct path for address.",
413		);
414		assert_eq!(
415			Bytes::from(dlf),
416			real_life_dlf,
417			"Failed to serialize real life DLF into the exact same contents as real life DLF!"
418		);
419
420		assert_eq!(
421			edlf.major_version(),
422			1,
423			"Empty DLF didn't parse correct major version!"
424		);
425		assert_eq!(
426			edlf.minor_version(),
427			0,
428			"Empty DLF didn't parse correct minor version!"
429		);
430		assert_eq!(
431			edlf.get_path_and_offset_for_file(0x0_u128).await,
432			Some((&PathBuf::new(), 0)),
433			"Empty dlf did not match correct path for address.",
434		);
435		assert_eq!(
436			Bytes::from(edlf),
437			empty_dlf,
438			"Failed to serialize empty DLF into the exact same contents as empty DLF!"
439		);
440	}
441}