Merge branch 'main' into fetch/download-signed-compliance-form

This commit is contained in:
François-X. T
2025-10-04 22:14:15 +02:00
19 changed files with 1395 additions and 1020 deletions
Generated
+1134 -929
View File
File diff suppressed because it is too large Load Diff
+4 -1
View File
@@ -8,6 +8,7 @@ members = [
"packages/app-lib", "packages/app-lib",
"packages/ariadne", "packages/ariadne",
"packages/daedalus", "packages/daedalus",
"packages/path-util",
] ]
[workspace.package] [workspace.package]
@@ -37,6 +38,7 @@ base64 = "0.22.1"
bitflags = "2.9.1" bitflags = "2.9.1"
bytemuck = "1.23.1" bytemuck = "1.23.1"
bytes = "1.10.1" bytes = "1.10.1"
typed-path = "0.11.0"
censor = "0.3.0" censor = "0.3.0"
chardetng = "0.1.17" chardetng = "0.1.17"
chrono = "0.4.41" chrono = "0.4.41"
@@ -46,6 +48,7 @@ clickhouse = "0.13.3"
color-thief = "0.2.2" color-thief = "0.2.2"
console-subscriber = "0.4.1" console-subscriber = "0.4.1"
daedalus = { path = "packages/daedalus" } daedalus = { path = "packages/daedalus" }
path-util = { path = "packages/path-util" }
dashmap = "6.1.0" dashmap = "6.1.0"
data-url = "0.3.1" data-url = "0.3.1"
deadpool-redis = "0.22.0" deadpool-redis = "0.22.0"
@@ -239,7 +242,7 @@ codegen-units = 1 # Compile crates one after another so the compiler can optimiz
# Specific profile for labrinth production builds # Specific profile for labrinth production builds
[profile.release-labrinth] [profile.release-labrinth]
inherits = "release" inherits = "release"
panic = "unwind" # Don't exit the whole app on panic in production panic = "unwind" # Don't exit the whole app on panic in production
[profile.dev.package.sqlx-macros] [profile.dev.package.sqlx-macros]
opt-level = 3 opt-level = 3
-10
View File
@@ -279,16 +279,6 @@ async function setupApp() {
const settings = await getSettings() const settings = await getSettings()
settings.pending_update_toast_for_version = null settings.pending_update_toast_for_version = null
await setSettings(settings) await setSettings(settings)
const version = await getVersion()
if (pending_update_toast_for_version === version) {
addNotification({
type: 'success',
title: formatMessage(messages.updateInstalledToastTitle, { version }),
text: formatMessage(messages.updateInstalledToastText),
clickAction: () => openUrl('https://modrinth.com/news/changelog?filter=app'),
})
}
} }
if (osType === 'windows') { if (osType === 'windows') {
+1
View File
@@ -11,6 +11,7 @@ tauri-build = { workspace = true, features = ["codegen"] }
[dependencies] [dependencies]
theseus = { workspace = true, features = ["tauri"] } theseus = { workspace = true, features = ["tauri"] }
path-util.workspace = true
serde_json.workspace = true serde_json.workspace = true
serde = { workspace = true, features = ["derive"] } serde = { workspace = true, features = ["derive"] }
+2 -1
View File
@@ -1,5 +1,6 @@
use crate::api::Result; use crate::api::Result;
use dashmap::DashMap; use dashmap::DashMap;
use path_util::SafeRelativeUtf8UnixPathBuf;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap; use std::collections::HashMap;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
@@ -239,7 +240,7 @@ pub async fn profile_export_mrpack(
#[tauri::command] #[tauri::command]
pub async fn profile_get_pack_export_candidates( pub async fn profile_get_pack_export_candidates(
profile_path: &str, profile_path: &str,
) -> Result<Vec<String>> { ) -> Result<Vec<SafeRelativeUtf8UnixPathBuf>> {
let candidates = profile::get_pack_export_candidates(profile_path).await?; let candidates = profile::get_pack_export_candidates(profile_path).await?;
Ok(candidates) Ok(candidates)
} }
+1
View File
@@ -133,6 +133,7 @@ rusty-money.workspace = true
json-patch.workspace = true json-patch.workspace = true
ariadne.workspace = true ariadne.workspace = true
path-util.workspace = true
clap = { workspace = true, features = ["derive"] } clap = { workspace = true, features = ["derive"] }
+2 -1
View File
@@ -1,6 +1,7 @@
use crate::{ use crate::{
models::v2::projects::LegacySideType, util::env::parse_strings_from_var, models::v2::projects::LegacySideType, util::env::parse_strings_from_var,
}; };
use path_util::SafeRelativeUtf8UnixPathBuf;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use validator::Validate; use validator::Validate;
@@ -23,7 +24,7 @@ pub struct PackFormat {
#[derive(Serialize, Deserialize, Validate, Eq, PartialEq, Debug, Clone)] #[derive(Serialize, Deserialize, Validate, Eq, PartialEq, Debug, Clone)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct PackFile { pub struct PackFile {
pub path: String, pub path: SafeRelativeUtf8UnixPathBuf,
pub hashes: std::collections::HashMap<PackFileHash, String>, pub hashes: std::collections::HashMap<PackFileHash, String>,
pub env: Option<std::collections::HashMap<EnvType, LegacySideType>>, // TODO: Should this use LegacySideType? Will probably require a overhaul of mrpack format to change this pub env: Option<std::collections::HashMap<EnvType, LegacySideType>>, // TODO: Should this use LegacySideType? Will probably require a overhaul of mrpack format to change this
#[validate(custom(function = "validate_download_url"))] #[validate(custom(function = "validate_download_url"))]
+1 -1
View File
@@ -304,7 +304,7 @@ impl AutomatedModerationQueue {
let hash = x.hashes.get(&PackFileHash::Sha1); let hash = x.hashes.get(&PackFileHash::Sha1);
if let Some(hash) = hash { if let Some(hash) = hash {
let path = x.path.clone(); let path = x.path.to_string();
Some((hash.clone(), Some(x), path, None)) Some((hash.clone(), Some(x), path, None))
} else { } else {
None None
+2 -3
View File
@@ -23,8 +23,7 @@ use ariadne::ids::base62_impl::{parse_base62, to_base62};
use chrono::{Duration, Utc}; use chrono::{Duration, Utc};
use rust_decimal::Decimal; use rust_decimal::Decimal;
use rust_decimal::prelude::ToPrimitive; use rust_decimal::prelude::ToPrimitive;
use serde::Serialize; use serde::{Deserialize, Serialize};
use serde_with::serde_derive::Deserialize;
use sqlx::{PgPool, Postgres, Transaction}; use sqlx::{PgPool, Postgres, Transaction};
use std::collections::HashMap; use std::collections::HashMap;
use std::str::FromStr; use std::str::FromStr;
@@ -290,7 +289,7 @@ pub async fn refund_charge(
currency_code: charge.currency_code.clone(), currency_code: charge.currency_code.clone(),
accounting_time: Utc::now(), accounting_time: Utc::now(),
accounting_time_zone: anrok::AccountingTimeZone::Utc, accounting_time_zone: anrok::AccountingTimeZone::Utc,
line_items: vec![anrok::LineItem::new_including_tax_amount(tax_id, refund_amount)], line_items: vec![anrok::LineItem::new_including_tax_amount(tax_id, -refund_amount)],
} }
} }
).await; ).await;
-19
View File
@@ -4,7 +4,6 @@ use crate::validate::{
SupportedGameVersions, ValidationError, ValidationResult, SupportedGameVersions, ValidationError, ValidationResult,
}; };
use std::io::{Cursor, Read}; use std::io::{Cursor, Read};
use std::path::Component;
use validator::Validate; use validator::Validate;
use zip::ZipArchive; use zip::ZipArchive;
@@ -72,24 +71,6 @@ impl super::Validator for ModpackValidator {
"All pack files must provide a SHA512 hash!".into(), "All pack files must provide a SHA512 hash!".into(),
)); ));
} }
let path = std::path::Path::new(&file.path)
.components()
.next()
.ok_or_else(|| {
ValidationError::InvalidInput(
"Invalid pack file path!".into(),
)
})?;
match path {
Component::CurDir | Component::Normal(_) => {}
_ => {
return Err(ValidationError::InvalidInput(
"Invalid pack file path!".into(),
));
}
};
} }
Ok(ValidationResult::PassWithPackDataAndFiles { Ok(ValidationResult::PassWithPackDataAndFiles {
+1
View File
@@ -114,6 +114,7 @@ hickory-resolver.workspace = true
zbus.workspace = true zbus.workspace = true
ariadne.workspace = true ariadne.workspace = true
path-util.workspace = true
[target.'cfg(windows)'.dependencies] [target.'cfg(windows)'.dependencies]
winreg.workspace = true winreg.workspace = true
@@ -6,6 +6,7 @@ use crate::state::{CachedEntry, LinkedData, ProfileInstallStage, SideType};
use crate::util::fetch::{fetch, fetch_advanced, write_cached_icon}; use crate::util::fetch::{fetch, fetch_advanced, write_cached_icon};
use crate::util::io; use crate::util::io;
use path_util::SafeRelativeUtf8UnixPathBuf;
use reqwest::Method; use reqwest::Method;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap; use std::collections::HashMap;
@@ -27,7 +28,7 @@ pub struct PackFormat {
#[derive(Serialize, Deserialize, Eq, PartialEq)] #[derive(Serialize, Deserialize, Eq, PartialEq)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct PackFile { pub struct PackFile {
pub path: String, pub path: SafeRelativeUtf8UnixPathBuf,
pub hashes: HashMap<PackFileHash, String>, pub hashes: HashMap<PackFileHash, String>,
pub env: Option<HashMap<EnvType, SideType>>, pub env: Option<HashMap<EnvType, SideType>>,
pub downloads: Vec<String>, pub downloads: Vec<String>,
+35 -35
View File
@@ -18,8 +18,8 @@ use super::install_from::{
generate_pack_from_version_id, generate_pack_from_version_id,
}; };
use crate::data::ProjectType; use crate::data::ProjectType;
use std::io::Cursor; use std::io::{Cursor, ErrorKind};
use std::path::{Component, PathBuf}; use std::path::PathBuf;
/// Install a pack /// Install a pack
/// Wrapper around install_pack_files that generates a pack creation description, and /// Wrapper around install_pack_files that generates a pack creation description, and
@@ -169,31 +169,22 @@ pub async fn install_zipped_mrpack_files(
) )
.await?; .await?;
let project_path = project.path.to_string(); let path = profile::get_full_path(&profile_path)
.await?
.join(project.path.as_str());
let path = cache_file_hash(
std::path::Path::new(&project_path).components().next(); file.clone(),
if let Some(Component::CurDir | Component::Normal(_)) = path &profile_path,
{ project.path.as_str(),
let path = profile::get_full_path(&profile_path) project.hashes.get(&PackFileHash::Sha1).map(|x| &**x),
.await? ProjectType::get_from_parent_folder(&path),
.join(&project_path); &state.pool,
)
.await?;
cache_file_hash( write(&path, &file, &state.io_semaphore).await?;
file.clone(),
&profile_path,
&project_path,
project
.hashes
.get(&PackFileHash::Sha1)
.map(|x| &**x),
ProjectType::get_from_parent_folder(&path),
&state.pool,
)
.await?;
write(&path, &file, &state.io_semaphore).await?;
}
Ok(()) Ok(())
} }
}, },
@@ -377,9 +368,10 @@ pub async fn remove_all_related_files(
if let Some(metadata) = &project.metadata if let Some(metadata) = &project.metadata
&& to_remove.contains(&metadata.project_id) && to_remove.contains(&metadata.project_id)
{ {
let path = profile_full_path.join(file_path); match io::remove_file(profile_full_path.join(file_path)).await {
if path.exists() { Ok(_) => (),
io::remove_file(&path).await?; Err(err) if err.kind() == ErrorKind::NotFound => (),
Err(err) => return Err(err.into()),
} }
} }
} }
@@ -387,9 +379,12 @@ pub async fn remove_all_related_files(
// Iterate over all Modrinth project file paths in the json, and remove them // Iterate over all Modrinth project file paths in the json, and remove them
// (There should be few, but this removes any files the .mrpack intended as Modrinth projects but were unrecognized) // (There should be few, but this removes any files the .mrpack intended as Modrinth projects but were unrecognized)
for file in pack.files { for file in pack.files {
let path: PathBuf = profile_full_path.join(file.path); match io::remove_file(profile_full_path.join(file.path.as_str()))
if path.exists() { .await
io::remove_file(&path).await?; {
Ok(_) => (),
Err(err) if err.kind() == ErrorKind::NotFound => (),
Err(err) => return Err(err.into()),
} }
} }
@@ -412,11 +407,16 @@ pub async fn remove_all_related_files(
} }
// Remove this file if a corresponding one exists in the filesystem // Remove this file if a corresponding one exists in the filesystem
let existing_file = profile::get_full_path(&profile_path) match io::remove_file(
.await? profile::get_full_path(&profile_path)
.join(&new_path); .await?
if existing_file.exists() { .join(&new_path),
io::remove_file(&existing_file).await?; )
.await
{
Ok(_) => (),
Err(err) if err.kind() == ErrorKind::NotFound => (),
Err(err) => return Err(err.into()),
} }
} }
} }
+29 -18
View File
@@ -18,6 +18,7 @@ use crate::util::io::{self, IOError};
pub use crate::{State, state::Profile}; pub use crate::{State, state::Profile};
use async_zip::tokio::write::ZipFileWriter; use async_zip::tokio::write::ZipFileWriter;
use async_zip::{Compression, ZipEntryBuilder}; use async_zip::{Compression, ZipEntryBuilder};
use path_util::SafeRelativeUtf8UnixPathBuf;
use serde_json::json; use serde_json::json;
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
@@ -497,11 +498,12 @@ pub async fn export_mrpack(
let version_id = version_id.unwrap_or("1.0.0".to_string()); let version_id = version_id.unwrap_or("1.0.0".to_string());
let mut packfile = let mut packfile =
create_mrpack_json(&profile, version_id, description).await?; create_mrpack_json(&profile, version_id, description).await?;
let included_candidates_set = let included_candidates_set = HashSet::<_>::from_iter(
HashSet::<_>::from_iter(included_export_candidates.iter()); included_export_candidates.iter().map(|x| x.as_str()),
);
packfile packfile
.files .files
.retain(|f| included_candidates_set.contains(&f.path)); .retain(|f| included_candidates_set.contains(f.path.as_str()));
// Build vec of all files in the folder // Build vec of all files in the folder
let mut path_list = Vec::new(); let mut path_list = Vec::new();
@@ -575,8 +577,8 @@ pub async fn export_mrpack(
#[tracing::instrument] #[tracing::instrument]
pub async fn get_pack_export_candidates( pub async fn get_pack_export_candidates(
profile_path: &str, profile_path: &str,
) -> crate::Result<Vec<String>> { ) -> crate::Result<Vec<SafeRelativeUtf8UnixPathBuf>> {
let mut path_list: Vec<String> = Vec::new(); let mut path_list = Vec::new();
let profile_base_dir = get_full_path(profile_path).await?; let profile_base_dir = get_full_path(profile_path).await?;
let mut read_dir = io::read_dir(&profile_base_dir).await?; let mut read_dir = io::read_dir(&profile_base_dir).await?;
@@ -610,18 +612,19 @@ pub async fn get_pack_export_candidates(
fn pack_get_relative_path( fn pack_get_relative_path(
profile_path: &PathBuf, profile_path: &PathBuf,
path: &PathBuf, path: &PathBuf,
) -> crate::Result<String> { ) -> crate::Result<SafeRelativeUtf8UnixPathBuf> {
Ok(path Ok(SafeRelativeUtf8UnixPathBuf::try_from(
.strip_prefix(profile_path) path.strip_prefix(profile_path)
.map_err(|_| { .map_err(|_| {
crate::ErrorKind::FSError(format!( crate::ErrorKind::FSError(format!(
"Path {path:?} does not correspond to a profile" "Path {path:?} does not correspond to a profile"
)) ))
})? })?
.components() .components()
.map(|c| c.as_os_str().to_string_lossy().to_string()) .map(|c| c.as_os_str().to_string_lossy())
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join("/")) .join("/"),
)?)
} }
/// Run Minecraft using a profile and the default credentials, logged in credentials, /// Run Minecraft using a profile and the default credentials, logged in credentials,
@@ -896,7 +899,15 @@ pub async fn create_mrpack_json(
.collect(); .collect();
Some(Ok(PackFile { Some(Ok(PackFile {
path, path: match path.try_into() {
Ok(path) => path,
Err(_) => {
return Some(Err(crate::ErrorKind::OtherError(
"Invalid file path in project".into(),
)
.as_error()));
}
},
hashes, hashes,
env: Some(env), env: Some(env),
downloads, downloads,
+3
View File
@@ -173,6 +173,9 @@ pub enum ErrorKind {
#[error("zbus error: {0}")] #[error("zbus error: {0}")]
ZbusError(#[from] zbus::Error), ZbusError(#[from] zbus::Error),
#[error("Deserialization error: {0}")]
DeserializationError(#[from] serde::de::value::Error),
} }
#[derive(Debug)] #[derive(Debug)]
+11 -1
View File
@@ -1,7 +1,10 @@
// IO error // IO error
// A wrapper around the tokio IO functions that adds the path to the error message, instead of the uninformative std::io::Error. // A wrapper around the tokio IO functions that adds the path to the error message, instead of the uninformative std::io::Error.
use std::{io::Write, path::Path}; use std::{
io::{ErrorKind, Write},
path::Path,
};
use tempfile::NamedTempFile; use tempfile::NamedTempFile;
use tokio::task::spawn_blocking; use tokio::task::spawn_blocking;
@@ -32,6 +35,13 @@ impl IOError {
path: path.to_string_lossy().to_string(), path: path.to_string_lossy().to_string(),
} }
} }
pub fn kind(&self) -> ErrorKind {
match self {
IOError::IOPathError { source, .. } => source.kind(),
IOError::IOError(source) => source.kind(),
}
}
} }
pub fn canonicalize( pub fn canonicalize(
+12
View File
@@ -0,0 +1,12 @@
[package]
name = "path-util"
edition.workspace = true
[dependencies]
typed-path.workspace = true
serde.workspace = true
derive_more = { workspace = true, features = ["display", "deref"] }
itertools.workspace = true
[lints]
workspace = true
+148
View File
@@ -0,0 +1,148 @@
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);
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");
}
}
+7
View File
@@ -10,6 +10,13 @@ export type VersionEntry = {
} }
const VERSIONS: VersionEntry[] = [ const VERSIONS: VersionEntry[] = [
{
date: `2025-10-044T09:45:00-07:00`,
product: 'app',
version: '0.10.9',
body: `### Security fixes
- Fixed a couple Modrinth Pack (\`.mrpack\`) importing security vulnerabilities.`,
},
{ {
date: `2025-10-01T19:05:00-07:00`, date: `2025-10-01T19:05:00-07:00`,
product: 'web', product: 'web',