Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
02769b0
test: define premium account domain contract
mpiton Jul 16, 2026
7ec68fe
test: require premium account persistence columns
mpiton Jul 16, 2026
d076a81
feat: persist typed premium account state
mpiton Jul 16, 2026
10ad93e
test: define scoped plugin credential lifecycle
mpiton Jul 16, 2026
427f2b4
feat: scope account credentials to plugin calls
mpiton Jul 16, 2026
a6da481
test: define plugin account validation bridge
mpiton Jul 16, 2026
d06cdb7
feat: bridge premium validation through plugins
mpiton Jul 16, 2026
ca6c548
test: require automatic account validation
mpiton Jul 16, 2026
d3e066d
feat: validate accounts on credential changes
mpiton Jul 16, 2026
07ec064
test: require state-aware account rotation
mpiton Jul 16, 2026
c770be2
feat: persist account rotation state
mpiton Jul 16, 2026
8df2028
test(accounts): define premium resolution contract
mpiton Jul 16, 2026
2099d20
feat(accounts): resolve hoster links with rotation
mpiton Jul 16, 2026
c7a07fd
test(accounts): define premium UI contract
mpiton Jul 16, 2026
2302908
feat(accounts): surface premium state in UI
mpiton Jul 16, 2026
3068d36
feat(accounts): wire premium services at runtime
mpiton Jul 16, 2026
8977c00
test(accounts): cover premium fallback states
mpiton Jul 16, 2026
1e1248f
test(plugin): expose credential boundary regressions
mpiton Jul 16, 2026
5ee3095
fix(plugin): bind credentials to selected plugin
mpiton Jul 16, 2026
c2053cf
test(accounts): expose state transition regressions
mpiton Jul 16, 2026
da088ee
fix(accounts): harden state transitions and rotation
mpiton Jul 16, 2026
91f0771
test(accounts): keep direct URLs inside native runtime
mpiton Jul 16, 2026
dbefb28
feat(accounts): resolve premium URLs just in time
mpiton Jul 16, 2026
ba81261
test(accounts): expose remaining UI contract gaps
mpiton Jul 16, 2026
8fff135
feat(accounts): expose typed validation status
mpiton Jul 16, 2026
e64058e
test(accounts): capture final review regressions
mpiton Jul 16, 2026
fa680e2
fix(accounts): close premium review gaps
mpiton Jul 16, 2026
3e0f273
test(accounts): pin final acceptance gaps
mpiton Jul 16, 2026
b4330ca
fix(accounts): enforce final premium invariants
mpiton Jul 16, 2026
0e65fb2
fix(account): close final premium acceptance gaps
mpiton Jul 16, 2026
0db4d05
chore(registry): bump vortex-mod-1fichier to 1.1.0
mpiton Jul 16, 2026
38c12d3
fix: address PR review comments
mpiton Jul 16, 2026
13123fd
fix: address PR review comments
mpiton Jul 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions CHANGELOG.md

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions registry/registry.toml
Original file line number Diff line number Diff line change
Expand Up @@ -112,11 +112,11 @@ min_vortex_version = "0.1.0"
name = "vortex-mod-1fichier"
description = "1fichier hoster — free (60s wait + captcha stub) and premium (API key)"
author = "vortex-community"
version = "1.0.0"
version = "1.1.0"
category = "hoster"
repository = "https://github.com/mpiton/vortex-mod-1fichier"
checksum_sha256 = "02035a49da81566cf7e4ff64cd0b5ed858e05dfdca643dbd14e53b359d93232c"
checksum_sha256_toml = "c16cb7d418ee6d261114b0ed66701629f7aa7b92317dc97fb7593ede9487c091"
checksum_sha256 = "bd2a19c45fd3681cb788ee43bdaf287028630a68c30bf8159692bd4bea087c23"
checksum_sha256_toml = "bcf3ba595c323d2d0a4ea680e4c6354285153020e07d1efe05c856d5e281dd6d"
official = true
min_vortex_version = "0.1.0"

Expand Down
375 changes: 328 additions & 47 deletions src-tauri/src/adapters/driven/network/download_engine.rs

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions src-tauri/src/adapters/driven/network/mod.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
mod checksum;
mod download_engine;
mod nat64;
mod reqwest_client;
mod safe_url;
mod segment_worker;
mod wait_manager;

pub use checksum::StreamingChecksumComputer;
pub use download_engine::SegmentedDownloadEngine;
pub use reqwest_client::ReqwestHttpClient;
pub(crate) use safe_url::{restricted_download_client, validate_public_url};
pub use wait_manager::WaitManager;

pub(super) fn format_error_chain(err: &(dyn std::error::Error + 'static)) -> String {
Expand Down
115 changes: 115 additions & 0 deletions src-tauri/src/adapters/driven/network/nat64.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
use std::net::{Ipv4Addr, Ipv6Addr, ToSocketAddrs};
use std::sync::OnceLock;

const PREFIX_LENGTHS: [u8; 6] = [32, 40, 48, 56, 64, 96];
const WELL_KNOWN_IPV4: [Ipv4Addr; 2] =
[Ipv4Addr::new(192, 0, 0, 170), Ipv4Addr::new(192, 0, 0, 171)];

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) struct Nat64Prefix {
network: u128,
length: u8,
}

impl Nat64Prefix {
pub(super) fn new(address: Ipv6Addr, length: u8) -> Option<Self> {
if !PREFIX_LENGTHS.contains(&length) {
return None;
}
Some(Self {
network: u128::from(address) & prefix_mask(length),
length,
})
}

pub(super) fn embedded_ipv4(self, address: Ipv6Addr) -> Option<Ipv4Addr> {
if u128::from(address) & prefix_mask(self.length) != self.network {
return None;
}
let bytes = address.octets();
if self.length < 96 && bytes[8] != 0 {
return None;
}
let octets = match self.length {
32 => [bytes[4], bytes[5], bytes[6], bytes[7]],
40 => [bytes[5], bytes[6], bytes[7], bytes[9]],
48 => [bytes[6], bytes[7], bytes[9], bytes[10]],
56 => [bytes[7], bytes[9], bytes[10], bytes[11]],
64 => [bytes[9], bytes[10], bytes[11], bytes[12]],
96 => [bytes[12], bytes[13], bytes[14], bytes[15]],
_ => return None,
};
Some(Ipv4Addr::from(octets))
}
}

pub(super) fn discovered_prefixes() -> &'static [Nat64Prefix] {
static PREFIXES: OnceLock<Vec<Nat64Prefix>> = OnceLock::new();
PREFIXES.get_or_init(|| {
let known = Ipv6Addr::new(0x64, 0xff9b, 0, 0, 0, 0, 0, 0);
let mut prefixes = Nat64Prefix::new(known, 96).into_iter().collect();
if let Ok(addresses) = ("ipv4only.arpa", 80).to_socket_addrs() {
for address in addresses.filter_map(|address| match address.ip() {
std::net::IpAddr::V6(ip) => Some(ip),
std::net::IpAddr::V4(_) => None,
}) {
discover_from_address(address, &mut prefixes);
}
}
prefixes
})
}

fn discover_from_address(address: Ipv6Addr, prefixes: &mut Vec<Nat64Prefix>) {
for length in PREFIX_LENGTHS {
let Some(prefix) = Nat64Prefix::new(address, length) else {
continue;
};
if prefix
.embedded_ipv4(address)
.is_some_and(|ipv4| WELL_KNOWN_IPV4.contains(&ipv4))
&& !prefixes.contains(&prefix)
{
prefixes.push(prefix);
}
}
}

fn prefix_mask(length: u8) -> u128 {
u128::MAX << (128 - length)
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn extracts_all_rfc_6052_layouts() {
for (address, length) in [
("2001:db8:c000:221::", 32),
("2001:db8:1c0:2:21::", 40),
("2001:db8:122:c000:2:2100::", 48),
("2001:db8:122:3c0:0:221::", 56),
("2001:db8:122:344:c0:2:2100::", 64),
("2001:db8:122:344::c000:221", 96),
] {
let address = address.parse().unwrap();
let prefix = Nat64Prefix::new(address, length).unwrap();
assert_eq!(
prefix.embedded_ipv4(address),
Some(Ipv4Addr::new(192, 0, 2, 33))
);
}
}

#[test]
fn accepts_96_prefixes_with_nonzero_fifth_segment() {
let address: Ipv6Addr = "2606:4700:64:1:ab00:cd00:c0a8:1".parse().unwrap();
let prefix = Nat64Prefix::new(address, 96).expect("valid /96 prefix");

assert_eq!(
prefix.embedded_ipv4(address),
Some(Ipv4Addr::new(192, 168, 0, 1))
);
}
}
131 changes: 131 additions & 0 deletions src-tauri/src/adapters/driven/network/safe_url.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
//! Network policy for URLs supplied by plugins rather than users.

use std::net::{IpAddr, SocketAddr, ToSocketAddrs};
use std::time::Duration;

use crate::domain::error::DomainError;

use super::nat64::{Nat64Prefix, discovered_prefixes};

pub(crate) fn validate_public_url(
Comment thread
mpiton marked this conversation as resolved.
url: &reqwest::Url,
) -> Result<Option<Vec<SocketAddr>>, DomainError> {
if url.scheme() != "https" {
return Err(DomainError::NetworkError(
"plugin URL must use HTTPS".into(),
));
}
let host = url
.host_str()
.ok_or_else(|| DomainError::NetworkError("URL has no host".into()))?;
if host == "localhost" || host.ends_with(".localhost") {
return Err(blocked());
}
if let Ok(ip) = host.parse::<IpAddr>() {
return if is_forbidden_ip(&ip) {
Err(blocked())
} else {
Ok(None)
};
}
let port = url
.port_or_known_default()
.ok_or_else(|| DomainError::NetworkError("URL has no known port".into()))?;
let addresses = (host, port)
.to_socket_addrs()
.map_err(|_| DomainError::NetworkError("host resolution failed".into()))?
.collect::<Vec<_>>();
if addresses.is_empty() || addresses.iter().any(|addr| is_forbidden_ip(&addr.ip())) {
return Err(blocked());
}
Ok(Some(addresses))
}

pub(crate) fn restricted_download_client(
url: &reqwest::Url,
) -> Result<reqwest::Client, DomainError> {
if !url.username().is_empty() || url.password().is_some() {
return Err(DomainError::NetworkError(
"plugin download URL must be credential-free HTTPS".into(),
));
}
let addresses = validate_public_url(url)?;
let mut builder = reqwest::Client::builder()
.no_proxy()
.user_agent("Vortex/0.1")
.redirect(reqwest::redirect::Policy::none())
.connect_timeout(Duration::from_secs(30))
.timeout(Duration::from_secs(3600));
if let (Some(host), Some(addresses)) = (url.host_str(), addresses.as_deref()) {
builder = builder.resolve_to_addrs(host, addresses);
}
builder
.build()
.map_err(|_| DomainError::NetworkError("restricted HTTP client creation failed".into()))
}

fn blocked() -> DomainError {
DomainError::NetworkError("plugin URL targets a non-public network".into())
}

pub(crate) fn is_forbidden_ip(ip: &IpAddr) -> bool {
let normalized = match ip {
IpAddr::V6(ip) if ip.to_ipv4_mapped().is_some() => IpAddr::V4(
ip.to_ipv4_mapped()
.unwrap_or(std::net::Ipv4Addr::UNSPECIFIED),
),
other => *other,
};
match normalized {
IpAddr::V4(ip) => {
let [a, b, c, d] = ip.octets();
a == 0
|| a == 10
|| a == 127
|| (a == 100 && (64..=127).contains(&b))
|| (a == 169 && b == 254)
|| (a == 172 && (16..=31).contains(&b))
|| (a == 192 && b == 0 && c == 0)
|| (a == 192 && b == 0 && c == 2)
|| (a == 192 && b == 168)
|| (a == 198 && (b == 18 || b == 19))
|| (a == 198 && b == 51 && c == 100)
|| (a == 203 && b == 0 && c == 113)
|| a >= 224
|| (a == 255 && b == 255 && c == 255 && d == 255)
}
IpAddr::V6(ip) => is_forbidden_ipv6(&ip, discovered_prefixes()),
}
}

fn is_forbidden_ipv6(ip: &std::net::Ipv6Addr, nat64: &[Nat64Prefix]) -> bool {
let segments = ip.segments();
let first = segments[0];
ip.is_loopback()
|| ip.is_unspecified()
|| ip.is_multicast()
|| (first & 0xfe00) == 0xfc00
|| (first & 0xffc0) == 0xfe80
|| (first & 0xffc0) == 0xfec0
|| matches!(segments, [0x0064, 0xff9b, 0x0001, ..])
|| matches!(segments, [0x0100, 0, 0, 0, ..])
|| is_orchid(&segments)
|| matches!(segments, [0x2001, 0x0002, 0, ..])
|| matches!(segments, [0x2001, 0x0db8, ..])
|| matches!(segments, [0x2002, ..])
|| matches!(segments, [0x3ff0..=0x3fff, ..])
|| matches!(segments, [0x5f00, ..])
|| nat64.iter().any(|prefix| {
prefix
.embedded_ipv4(*ip)
.is_some_and(|ipv4| is_forbidden_ip(&IpAddr::V4(ipv4)))
})
}

fn is_orchid(segments: &[u16; 8]) -> bool {
segments[0] == 0x2001 && matches!(segments[1] & 0xfff0, 0x0010 | 0x0020 | 0x0030)
}

#[cfg(test)]
#[path = "safe_url_tests.rs"]
mod tests;
46 changes: 46 additions & 0 deletions src-tauri/src/adapters/driven/network/safe_url_tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
use super::*;

#[test]
fn rejects_loopback_private_link_local_and_mapped_addresses() {
for raw in [
"127.0.0.1",
"10.0.0.1",
"169.254.169.254",
"::ffff:127.0.0.1",
"fec0::1",
"64:ff9b::c0a8:1",
"64:ff9b:1::c0a8:1",
"2001:10::1",
"2001:20::1",
] {
assert!(is_forbidden_ip(&raw.parse().unwrap()), "{raw}");
}
}

#[test]
fn rejects_private_ipv4_embedded_in_discovered_operator_nat64_prefix() {
let prefix = Nat64Prefix::new("2606:4700:64::".parse().unwrap(), 96).unwrap();
let target = "2606:4700:64::c0a8:1".parse().unwrap();

assert!(is_forbidden_ipv6(&target, &[prefix]));
}

#[test]
fn accepts_globally_routable_ipv6_address() {
assert!(!is_forbidden_ip(&"2606:4700:4700::1111".parse().unwrap()));
}

#[test]
fn restricted_client_requires_https_and_public_destination() {
let http = reqwest::Url::parse("http://1.1.1.1/file").unwrap();
let local = reqwest::Url::parse("https://127.0.0.1/file").unwrap();
assert!(restricted_download_client(&http).is_err());
assert!(restricted_download_client(&local).is_err());
}

#[test]
fn plugin_http_validator_rejects_cleartext_urls() {
let url = reqwest::Url::parse("http://1.1.1.1/account").unwrap();

assert!(validate_public_url(&url).is_err());
}
Loading
Loading