cat_dev/fsemul/pcfs/sata_proto/
remove.rs

1//! Definitions, and handlers for the `Remove` packet type.
2//!
3//! This does destructive things, and removes files from your filesystem. You
4//! can disable the behavior of truly "removing" items from your filesystem,
5//! and configure your PCFS client to just move files to `.rm`
6
7use crate::{
8	errors::{CatBridgeError, FSError, NetworkParseError},
9	fsemul::{
10		host_filesystem::ResolvedLocation,
11		pcfs::sata_proto::{construct_sata_response, SataPacketHeader},
12		HostFilesystem,
13	},
14};
15use bytes::{BufMut, Bytes, BytesMut};
16use std::{
17	ffi::{CStr, OsStr, OsString},
18	path::PathBuf,
19};
20use tokio::fs::{create_dir_all, read_link, remove_dir_all, remove_file, rename};
21use tracing::error;
22use valuable::{Fields, NamedField, NamedValues, StructDef, Structable, Valuable, Value, Visit};
23use walkdir::WalkDir;
24
25/// A filesystem error occured.
26const FS_ERROR: u32 = 0xFFF0_FFE0;
27/// An error code to send when a path does not exist.
28///
29/// This is also used in some places that are a bit of a stretch like for
30/// network shares on disk space. The path doesn't exist on a disk, so this
31/// error code is used, even if it's not quite exact.
32const PATH_NOT_EXIST_ERROR: u32 = 0xFFF0_FFE9;
33
34/// A packet to remove a file/directory.
35#[derive(Clone, Debug, PartialEq, Eq)]
36pub struct SataRemovePacketBody {
37	/// The path to remove, note that this is not the 'resolved' path which is
38	/// the path to actual read from.
39	///
40	/// Interpolation has a few known ways of being replaced:
41	///
42	/// - `%MLC_EMU_DIR`: `<cafe_sdk>/data/mlc/`
43	/// - `%SLC_EMU_DIR`: `<cafe_sdk>/data/slc/`
44	/// - `%DISC_EMU_DIR`: `<cafe_sdk>/data/disc/`
45	/// - `%SAVE_EMU_DIR`: `<cafe_sdk>/data/save/`
46	/// - `%NETWORK`: <mounted network share path>
47	path: String,
48}
49
50impl SataRemovePacketBody {
51	#[must_use]
52	pub fn path(&self) -> &str {
53		self.path.as_str()
54	}
55
56	/// Handle removing a file upon request.
57	///
58	/// ## Errors
59	///
60	/// If we cannot construct a sata response packet because our data to send
61	/// was somehow too large (this should ideally never happen).
62	#[allow(
63		// This is far easier to read not collapsed.
64		clippy::collapsible_else_if,
65	)]
66	pub async fn handle(
67		&self,
68		request_header: &SataPacketHeader,
69		actually_do_remove: bool,
70		host_filesystem: &HostFilesystem,
71	) -> Result<Bytes, CatBridgeError> {
72		let Ok(final_location) = host_filesystem.resolve_path(&self.path) else {
73			return Self::construct_error(request_header, PATH_NOT_EXIST_ERROR);
74		};
75		let ResolvedLocation::Filesystem(fs_location) = final_location else {
76			todo!("network shares not yet implemented!")
77		};
78
79		if fs_location.resolved_path().exists() {
80			if actually_do_remove {
81				if fs_location.resolved_path().is_file() {
82					if let Err(cause) = remove_file(fs_location.resolved_path()).await {
83						error!(
84						  ?cause,
85						  path = %fs_location.resolved_path().display(),
86						  "Failed to remove file as requested by PCFS.",
87						);
88						return Self::construct_error(request_header, FS_ERROR);
89					}
90				} else if fs_location.resolved_path().is_dir() {
91					if let Err(cause) = remove_dir_all(fs_location.resolved_path()).await {
92						error!(
93						  ?cause,
94						  path = %fs_location.resolved_path().display(),
95						  "Failed to remove directory as requested by PCFS."
96						);
97						return Self::construct_error(request_header, FS_ERROR);
98					}
99				} else {
100					return Self::construct_error(request_header, FS_ERROR);
101				}
102			} else {
103				if fs_location.resolved_path().is_file() {
104					// This should always be fine to do as mount pounts are at most
105					// specific to a directory, so moving a file within the same directory
106					// doesn't violate the "can't move across mount points" on windows.
107					let mut new_filename = fs_location
108						.resolved_path()
109						.file_name()
110						.unwrap_or_default()
111						.to_owned();
112					new_filename.push(OsStr::new(".rm"));
113					let mut new_path = fs_location.resolved_path().clone();
114					new_path.pop();
115					new_path.push(new_filename);
116
117					if let Err(cause) = rename(fs_location.resolved_path(), new_path).await {
118						error!(
119						  ?cause,
120						  path = %fs_location.resolved_path().display(),
121						  "Failed to rename file (as opposed to remove) as requested by PCFS."
122						);
123						return Self::construct_error(request_header, FS_ERROR);
124					}
125				} else if fs_location.resolved_path().is_dir() {
126					if let Err(cause) = Self::rename_dir(fs_location.resolved_path()).await {
127						error!(
128						  ?cause,
129						  path = %fs_location.resolved_path().display(),
130						  "Failed to rename folder (as opposed to remove) as requested by PCFS."
131						);
132						return Self::construct_error(request_header, FS_ERROR);
133					}
134				} else {
135					return Self::construct_error(request_header, FS_ERROR);
136				}
137			}
138		}
139
140		Ok(construct_sata_response(
141			request_header,
142			0,
143			BytesMut::zeroed(4).freeze(),
144		)?)
145	}
146
147	/// Rename an entire directory.
148	///
149	/// We have to implement this ourselves, because [`tokio::fs::rename`], and
150	/// [`std::fs::rename`] don't support renaming a directory at all on windows,
151	/// which is one of the critical OS's that we need to support.
152	///
153	/// This 'rename' works by actually creating a new directory with the ".rm"
154	/// added. Then moving all the files over with rename. This is slow, but
155	/// works.
156	async fn rename_dir(old_path: &PathBuf) -> Result<(), FSError> {
157		let mut new_filename = old_path.file_name().unwrap_or_default().to_owned();
158		new_filename.push(OsStr::new(".rm"));
159		let mut new_path = old_path.clone();
160		new_path.pop();
161		new_path.push(new_filename);
162		let old_path_bytes = old_path.as_os_str().as_encoded_bytes();
163		let new_path_as_str_bytes = new_path.as_os_str().as_encoded_bytes();
164
165		create_dir_all(&new_path).await?;
166		for result in WalkDir::new(old_path)
167			.follow_links(false)
168			.follow_root_links(false)
169		{
170			let rpb = result?.into_path();
171			let os_str_for_entry = rpb.as_os_str().as_encoded_bytes();
172			let mut new_bytes = Vec::with_capacity(os_str_for_entry.len() + 3);
173			new_bytes.extend_from_slice(new_path_as_str_bytes);
174			new_bytes.extend_from_slice(&os_str_for_entry[old_path_bytes.len()..]);
175			let as_new_path =
176				PathBuf::from(unsafe { OsString::from_encoded_bytes_unchecked(new_bytes) });
177
178			if rpb.is_symlink() {
179				let mut resolved_path = read_link(&rpb).await?;
180				{
181					// Rewrite paths within the directory we're removing.
182					let os_str_for_resolved = resolved_path.as_os_str().as_encoded_bytes();
183					if os_str_for_resolved.starts_with(old_path_bytes) {
184						let mut new_bytes = Vec::with_capacity(os_str_for_resolved.len() + 3);
185						new_bytes.extend_from_slice(new_path_as_str_bytes);
186						new_bytes.extend_from_slice(&os_str_for_entry[old_path_bytes.len()..]);
187						resolved_path = PathBuf::from(unsafe {
188							OsString::from_encoded_bytes_unchecked(new_bytes)
189						});
190					}
191				}
192
193				#[cfg(unix)]
194				{
195					use std::os::unix::fs::symlink;
196					symlink(resolved_path, &as_new_path)?;
197				}
198
199				#[cfg(target_os = "windows")]
200				{
201					use std::os::windows::fs::{symlink_dir, symlink_file};
202
203					if resolved_path.is_dir() {
204						symlink_dir(resolved_path, &as_new_path)?;
205					} else {
206						symlink_file(resolved_path, &as_new_path)?;
207					}
208				}
209			} else if rpb.is_file() {
210				rename(&rpb, &as_new_path).await?;
211			} else if rpb.is_dir() {
212				create_dir_all(as_new_path).await?;
213			}
214		}
215
216		remove_dir_all(old_path).await?;
217		Ok(())
218	}
219
220	fn construct_error(
221		packet_header: &SataPacketHeader,
222		error_code: u32,
223	) -> Result<Bytes, CatBridgeError> {
224		let mut buff = BytesMut::with_capacity(8);
225		buff.put_u32(error_code);
226		buff.put_u32(0);
227
228		Ok(construct_sata_response(packet_header, 0, buff.freeze())?)
229	}
230}
231
232impl TryFrom<Bytes> for SataRemovePacketBody {
233	type Error = NetworkParseError;
234
235	fn try_from(value: Bytes) -> Result<Self, Self::Error> {
236		if value.len() < 0x200 {
237			return Err(NetworkParseError::FieldNotLongEnough(
238				"SataRemove",
239				"Body",
240				0x200,
241				value.len(),
242				value,
243			));
244		}
245		if value.len() > 0x200 {
246			return Err(NetworkParseError::UnexpectedTrailer(
247				"SataRemove",
248				value.slice(0x200..),
249			));
250		}
251
252		let path_c_str =
253			CStr::from_bytes_until_nul(&value).map_err(NetworkParseError::BadCString)?;
254
255		Ok(Self {
256			path: path_c_str.to_str()?.to_owned(),
257		})
258	}
259}
260
261const SATA_REMOVE_PACKET_BODY_FIELDS: &[NamedField<'static>] = &[NamedField::new("path")];
262
263impl Structable for SataRemovePacketBody {
264	fn definition(&self) -> StructDef<'_> {
265		StructDef::new_static(
266			"SataRemovePacketBody",
267			Fields::Named(SATA_REMOVE_PACKET_BODY_FIELDS),
268		)
269	}
270}
271
272impl Valuable for SataRemovePacketBody {
273	fn as_value(&self) -> Value<'_> {
274		Value::Structable(self)
275	}
276
277	fn visit(&self, visitor: &mut dyn Visit) {
278		visitor.visit_named_fields(&NamedValues::new(
279			SATA_REMOVE_PACKET_BODY_FIELDS,
280			&[Valuable::as_value(&self.path)],
281		));
282	}
283}
284
285#[cfg(test)]
286mod unit_tests {
287	use super::*;
288	use crate::fsemul::host_filesystem::test_helpers::{
289		create_temporary_host_filesystem, join_many,
290	};
291
292	#[tokio::test]
293	pub async fn test_real_removal() {
294		let (tempdir, fs) = create_temporary_host_filesystem().await;
295
296		let base_dir = join_many(tempdir.path(), ["a", "b", "c"]);
297		tokio::fs::create_dir_all(&base_dir)
298			.await
299			.expect("Failed to create temporary directory for test!");
300		let file_path = join_many(&base_dir, ["file.txt"]);
301		tokio::fs::write(&file_path, vec![0; 1307])
302			.await
303			.expect("Failed to write test file!");
304
305		let request = SataRemovePacketBody {
306			path: base_dir
307				.to_str()
308				.expect("Test paths must be UTF-8")
309				.to_owned(),
310		};
311		let mocked_header = SataPacketHeader {
312			packet_data_len: 0,
313			packet_id: 0,
314			flags: 0,
315			version: 0,
316			timestamp_on_host: 0,
317			pid_on_host: 0,
318		};
319
320		let bytes = request
321			.handle(&mocked_header, true, &fs)
322			.await
323			.expect("Failed to handle removal that was fake!");
324
325		assert!(
326			!base_dir.exists(),
327			"Base directory still exists post 'removal', response:\n\n  {:02X?}\n",
328			bytes,
329		);
330	}
331
332	#[tokio::test]
333	pub async fn test_fake_removal() {
334		let (tempdir, fs) = create_temporary_host_filesystem().await;
335
336		let base_dir = join_many(tempdir.path(), ["a", "b", "c"]);
337		tokio::fs::create_dir_all(&base_dir)
338			.await
339			.expect("Failed to create temporary directory for test!");
340		let file_path = join_many(&base_dir, ["file.txt"]);
341		tokio::fs::write(&file_path, vec![0; 1307])
342			.await
343			.expect("Failed to write test file!");
344
345		let inner_path = join_many(tempdir.path(), ["a", "b", "c", "d", "e"]);
346		tokio::fs::create_dir_all(&inner_path)
347			.await
348			.expect("Failed to create temporary directory for test!");
349
350		let directory_to_symlink = join_many(tempdir.path(), ["data", "slc"]);
351		let dir_path_to_symlink = join_many(tempdir.path(), ["a", "b", "c", "d", "e", "f"]);
352
353		let file_path_to_symlink = join_many(
354			tempdir.path(),
355			["a", "b", "c", "d", "e", "symlinked-file.txt"],
356		);
357
358		#[cfg(unix)]
359		{
360			use std::os::unix::fs::symlink;
361
362			symlink(&directory_to_symlink, &dir_path_to_symlink)
363				.expect("Failed to symlink directory!");
364			symlink(&file_path, &file_path_to_symlink).expect("Failed to symlink file!");
365		}
366
367		#[cfg(target_os = "windows")]
368		{
369			use std::os::windows::fs::{symlink_dir, symlink_file};
370
371			symlink_dir(&directory_to_symlink, &dir_path_to_symlink)
372				.expect("Failed to symlink directory!");
373			symlink_file(&file_path, &file_path_to_symlink).expect("Failed to symlink file!");
374		}
375
376		let request = SataRemovePacketBody {
377			path: base_dir
378				.to_str()
379				.expect("Test paths must be UTF-8")
380				.to_owned(),
381		};
382		let mocked_header = SataPacketHeader {
383			packet_data_len: 0,
384			packet_id: 0,
385			flags: 0,
386			version: 0,
387			timestamp_on_host: 0,
388			pid_on_host: 0,
389		};
390
391		let _ = request
392			.handle(&mocked_header, false, &fs)
393			.await
394			.expect("Failed to handle removal that was fake!");
395		let renamed_dir = join_many(tempdir.path(), ["a", "b", "c.rm"]);
396
397		assert!(
398			!base_dir.exists(),
399			"Base directory still exists post 'removal'",
400		);
401		assert!(renamed_dir.exists(), "Renamed directory doesn't exist?");
402	}
403}