mirror of
https://github.com/modrinth/code.git
synced 2026-08-25 17:14:50 +00:00
* feat: implement instance share page + search_users backend call * feat: invite players modal * feat: use tanstack queries for friends sync across app pages * feat: base shared instances implementation * fix: admon style * feat: impl instance admonitions like server panel * fix: impl get + del usage * feat: support modpack links * feat: invite notif accepting * fix: lint + fmt * feat: impl install to play * feat: impl usage of UpdateToPlayModal * feat: warnings on deleting/disabling shared-instance version content * fix: send instance name * feat: align with backend * feat: shared instances qa * feat: wrong account protection * feat: qa * fix: smartly apply updates * fix: install bug * fix: 401/404 differentiation * fix: fmt+prepr * feat: qa * feat: qa * fix: signing out messes up revoke/deleted checks * feat: qa * fix: fmt + lint * feat: lock content if part of shared instance * fix: lint * [do not merge] feat: rough invite links impl temp (#6666) * fix: wrong cmd * feat: invite page * fix: server-manager DTO mismatch * fix: drop anonymous invite link acceptance * refactor: structured shared-instance unavailable errors * refactor: centralise error presentations * refactor: dedupe shared instance diff detection * fix: logging in reqwests * refactor: move app.vue shared instances into handler * refactor: break up Share.vue * refactor: split up shared instances state outside of instance index * refactor: dedicated shared instances install/update modals + split up page * refactor: centralized managed content * refactor: split up install shared to own runner + shared.rs split up * refactor: dedupe sql for instance metadata enrichmnt * refactor: friends composable + dedupe friends logic across usages * chore: reduced unused code * fix: align with backend * fix: lint * fix: file sha changes * fix: invite links not working due to icon signed * feat: qa * feat: reporting frontend dummy * fix: try use header * remove: file hash field * fix: pin box * feat: malware warning for shared instances * fix: cache rule * feat: config files syncing * feat: disable config sharing * fix: header * fix: use mark ready * fix: dont cause push update for configs * fix: lint * feat: sharing page in settings * feat: move config + change flow * fix: qa * fix: lint prepr * feat: proxy file upload thru shared instances backend * fix: use collapisible * fix: push config * fix: config * feat: swap out sign in modal for new one * fix: report flow * fix: exclude configs.zip from external warnings * fix: nuxi init * fix: config bundle downloading * fix: error notif * fix: polling * fix: qa * fix: lint + prepr * feat: shared instances moderation frontend + hook up report flow * fix: report copy * fix: lint * fix: lint * fix: modrinth ids being undefined * feat: instance quarantining * fix: prepr + fmt * fix: quarantined -> locked terminology * fix: missing endpoint impls + fmt * fix: missing api in build.rs * fix: share tab jittery * fix: fmt *PT bug * fix: invites count as users even if pending * fix: prepr * fix: invite page owner in users list * fix: lint * fix: qa * fix: lint * fix: members stale not clearing * fix: invite use joined_at field * fix: lint * fix: qa --------- Co-authored-by: sychic <47618543+Sychic@users.noreply.github.com>
192 lines
7.0 KiB
Rust
192 lines
7.0 KiB
Rust
use itertools::Itertools;
|
|
use serde::{
|
|
Deserialize, Deserializer, Serialize, Serializer,
|
|
de::value::StringDeserializer,
|
|
};
|
|
use typed_path::{
|
|
Utf8Component, Utf8TypedPathBuf, Utf8UnixComponent, Utf8UnixPathBuf,
|
|
};
|
|
|
|
#[derive(
|
|
Eq, PartialEq, Hash, Debug, Clone, derive_more::Display, derive_more::Deref,
|
|
)]
|
|
#[repr(transparent)]
|
|
pub struct SafeRelativeUtf8UnixPathBuf(Utf8UnixPathBuf);
|
|
|
|
pub fn is_safe_file_name(file_name: &str) -> bool {
|
|
if file_name.contains('/') || file_name.contains('\\') {
|
|
return false;
|
|
}
|
|
|
|
SafeRelativeUtf8UnixPathBuf::try_from(file_name.to_string()).is_ok_and(
|
|
|path| {
|
|
path.components()
|
|
.exactly_one()
|
|
.is_ok_and(|component| component.is_normal())
|
|
},
|
|
)
|
|
}
|
|
|
|
impl<'de> Deserialize<'de> for SafeRelativeUtf8UnixPathBuf {
|
|
fn deserialize<D: Deserializer<'de>>(
|
|
deserializer: D,
|
|
) -> Result<Self, D::Error> {
|
|
// When parsed successfully, the path is guaranteed to be free from leading backslashes
|
|
// and Windows prefixes (e.g., `C:`)
|
|
let Utf8TypedPathBuf::Unix(path) =
|
|
Utf8TypedPathBuf::from(String::deserialize(deserializer)?)
|
|
else {
|
|
return Err(serde::de::Error::custom(
|
|
"File path must be a Unix-style relative path",
|
|
));
|
|
};
|
|
|
|
let mut path_components = path.components().peekable();
|
|
|
|
if path_components.peek().is_none() {
|
|
return Err(serde::de::Error::custom("File path cannot be empty"));
|
|
}
|
|
|
|
// All components should be normal: a file or directory name, not `/`, or `..`,
|
|
// and not refer to any reserved Windows device name. Also, at this point we may have
|
|
// a pseudo-Unix path like `my\directory`, which we should reject by filtering out
|
|
// backslashes to guarantee consistent cross-platform behavior when interpreting component
|
|
// separators
|
|
if !path_components.all(|component| {
|
|
(component.is_normal() || component.is_current())
|
|
&& !component.as_str().contains('\\')
|
|
&& !is_reserved_windows_device_name(&component)
|
|
}) {
|
|
return Err(serde::de::Error::custom(
|
|
"File path cannot contain any special component, prefix, reserved Windows device name, or backslashes",
|
|
));
|
|
}
|
|
|
|
Ok(Self(path))
|
|
}
|
|
}
|
|
|
|
impl Serialize for SafeRelativeUtf8UnixPathBuf {
|
|
fn serialize<S: Serializer>(
|
|
&self,
|
|
serializer: S,
|
|
) -> Result<S::Ok, S::Error> {
|
|
let mut path_components = self.0.components().peekable();
|
|
|
|
if path_components.peek().is_none() {
|
|
return Err(serde::ser::Error::custom("File path cannot be empty"));
|
|
}
|
|
|
|
if !path_components.all(|component| {
|
|
(component.is_normal() || component.is_current())
|
|
&& !component.as_str().contains('\\')
|
|
&& !is_reserved_windows_device_name(&component)
|
|
}) {
|
|
return Err(serde::ser::Error::custom(
|
|
"File path cannot contain any special component, prefix, reserved Windows device name, or backslashes",
|
|
));
|
|
}
|
|
|
|
// Iterating over components does basic normalization by e.g. removing redundant
|
|
// slashes and collapsing `.` components, so do that to produce a cleaner output
|
|
// friendlier to the strict deserialization algorithm above
|
|
self.0.components().join("/").serialize(serializer)
|
|
}
|
|
}
|
|
|
|
impl TryFrom<String> for SafeRelativeUtf8UnixPathBuf {
|
|
type Error = serde::de::value::Error;
|
|
|
|
fn try_from(s: String) -> Result<Self, Self::Error> {
|
|
Self::deserialize(StringDeserializer::new(s))
|
|
}
|
|
}
|
|
|
|
fn is_reserved_windows_device_name(component: &Utf8UnixComponent) -> bool {
|
|
let file_name = component.as_str().to_ascii_uppercase();
|
|
|
|
// Windows reserves some special DOS device names in every directory, which may be optionally
|
|
// followed by an extension or alternate data stream name and be case insensitive. Trying to
|
|
// write, read, or delete these files is usually not that useful even for malware, since they
|
|
// mostly refer to console and printer devices, but it's best to avoid them entirely anyway.
|
|
// References:
|
|
// https://learn.microsoft.com/en-us/windows/win32/fileio/naming-a-file#naming-conventions
|
|
// https://devblogs.microsoft.com/oldnewthing/20031022-00/?p=42073
|
|
// https://github.com/wine-mirror/wine/blob/01269452e0fbb1f081d506bd64996590a553e2b9/dlls/ntdll/path.c#L66
|
|
const RESERVED_WINDOWS_DEVICE_NAMES: &[&str] = &[
|
|
"CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5",
|
|
"COM6", "COM7", "COM8", "COM9", "COM¹", "COM²", "COM³", "LPT1", "LPT2",
|
|
"LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9", "LPT¹", "LPT²",
|
|
"LPT³", "CONIN$", "CONOUT$",
|
|
];
|
|
|
|
RESERVED_WINDOWS_DEVICE_NAMES.iter().any(|name| {
|
|
file_name.starts_with(name)
|
|
&& (file_name[name.len()..].is_empty()
|
|
|| file_name[name.len()..].starts_with('.')
|
|
|| file_name[name.len()..].starts_with(':'))
|
|
})
|
|
}
|
|
|
|
#[test]
|
|
fn safe_relative_path_deserialization_contract() {
|
|
let valid_paths = [
|
|
"file.txt",
|
|
"directory/file.txt",
|
|
"my-directory/file.name.with.dots.tar.gz",
|
|
"my_directory/123_456-789.file",
|
|
"./my/file.txt",
|
|
"my/./file.txt",
|
|
];
|
|
for path in valid_paths {
|
|
SafeRelativeUtf8UnixPathBuf::try_from(path.to_string())
|
|
.expect("Path should be considered valid");
|
|
}
|
|
|
|
let invalid_paths = [
|
|
"", // Empty path
|
|
"/absolute/file.txt", // Absolute path
|
|
"C:/absolute/file.txt", // Absolute path with common Windows prefix
|
|
"//server/share/file.txt", // Absolute path with Windows UNC prefix
|
|
"directory/../file.txt", // Path with `..` component
|
|
"CON.txt", // Reserved Windows device name
|
|
"NUL/file.txt", // Reserved Windows device name "directory"
|
|
"COM1.txt:ads", // Reserved Windows device name with ADS name
|
|
"file\\name.txt", // Backslash in file name
|
|
"my\\directory/file.txt", // Backslash in directory name
|
|
];
|
|
for path in invalid_paths {
|
|
SafeRelativeUtf8UnixPathBuf::try_from(path.to_string())
|
|
.expect_err("Path should be considered invalid");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn safe_file_name_contract() {
|
|
let valid_file_names = [
|
|
"file.txt",
|
|
"file.name.with.dots.tar.gz",
|
|
"file..name.jar",
|
|
"123_456-789.file",
|
|
];
|
|
for file_name in valid_file_names {
|
|
assert!(is_safe_file_name(file_name));
|
|
}
|
|
|
|
let invalid_file_names = [
|
|
"",
|
|
".",
|
|
"..",
|
|
"../file.txt",
|
|
"directory/file.txt",
|
|
r"..\file.txt",
|
|
r"directory\file.txt",
|
|
"C:file.txt",
|
|
"C:/file.txt",
|
|
"NUL.txt",
|
|
];
|
|
for file_name in invalid_file_names {
|
|
assert!(!is_safe_file_name(file_name));
|
|
}
|
|
}
|