Files
modrinth/packages/path-util/src/lib.rs
T
Calum H.andsychic e58af98f21 feat: instance sharing thru shared-instances service (#6569)
* 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>
2026-07-24 13:06:38 +00:00

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));
}
}