From 4724967312926705bb48d02e6d878394fd0dd3e8 Mon Sep 17 00:00:00 2001 From: Mathious6 <91007976+Mathious6@users.noreply.github.com> Date: Mon, 7 Sep 2026 20:24:42 +0200 Subject: [PATCH 1/2] feat(export): bundle multi-subnet export into a zip with a combined txt --- README.md | 6 +- src-tauri/Cargo.lock | 22 ++- src-tauri/Cargo.toml | 1 + src-tauri/src/archive.rs | 110 ++++++++++++++ src-tauri/src/export.rs | 275 +++++++++++++++++++++++++++++++++-- src-tauri/src/lib.rs | 1 + src-tauri/src/secure_file.rs | 31 +++- 7 files changed, 420 insertions(+), 26 deletions(-) create mode 100644 src-tauri/src/archive.rs diff --git a/README.md b/README.md index 0171198..e4e4a43 100644 --- a/README.md +++ b/README.md @@ -84,11 +84,13 @@ usually already have it. Windows installers are unsigned. table fills with OK, Connect, TTFB (p50 and p95), and Last probe. Those stats survive a restart. Adding proxies to a `/24` drops its last probe. Failures are omitted from those timings. -6. **Export** writes one `.txt` per selected subnet, or every subnet when none +6. **Export** writes one `.txt` for a single subnet, or every subnet when none is selected, source lines verbatim: `[{tags}_]{CC}_{IP}_24_{qty}.txt`. Example: `isp-mobile_FR_192.0.2.0_24_42.txt`. No tags means the filename starts - with the country code. No country means `XX`. + with the country code. No country means `XX`. Exporting several subnets + at once produces a single `.zip` holding every subnet's `.txt` plus an + `ALL_.txt` with all proxy lines for easy bulk import. The adjacent export menu also offers **Export for AYCD (.json)**, which writes one categorized JSON file for the same scope. diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index eb816c4..788189e 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2813,6 +2813,7 @@ dependencies = [ "url", "uuid", "webpki-roots 1.0.9", + "zip 8.6.0", ] [[package]] @@ -3947,7 +3948,7 @@ dependencies = [ "tokio", "url", "windows-sys 0.60.2", - "zip", + "zip 4.6.1", ] [[package]] @@ -4440,6 +4441,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "typed-path" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" + [[package]] name = "typeid" version = "1.0.3" @@ -5575,6 +5582,19 @@ dependencies = [ "memchr", ] +[[package]] +name = "zip" +version = "8.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b" +dependencies = [ + "crc32fast", + "flate2", + "indexmap 2.14.0", + "memchr", + "typed-path", +] + [[package]] name = "zmij" version = "1.0.23" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index fc85c82..97e2fb0 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -33,6 +33,7 @@ rustls-pki-types = "1" tauri-plugin-updater = { git = "https://github.com/tauri-apps/plugins-workspace", rev = "71df1a095d007fb94f0eb07940b0a78e57ac984e", version = "2.10.1" } tauri-plugin-process = "2" tauri-plugin-opener = "2" +zip = { version = "8.6.0", default-features = false, features = ["deflate-flate2"] } [dev-dependencies] rcgen = "0.13" diff --git a/src-tauri/src/archive.rs b/src-tauri/src/archive.rs new file mode 100644 index 0000000..67690f6 --- /dev/null +++ b/src-tauri/src/archive.rs @@ -0,0 +1,110 @@ +use std::io::Write; +use std::path::Path; + +use zip::write::SimpleFileOptions; +use zip::CompressionMethod; +use zip::ZipWriter; + +pub(crate) fn write_zip(path: &Path, entries: &[(String, String)]) -> Result<(), String> { + let file = crate::secure_file::open_truncating(path).map_err(|err| io_error(path, err))?; + let mut writer = ZipWriter::new(file); + let options = SimpleFileOptions::default() + .compression_method(CompressionMethod::Deflated) + .unix_permissions(0o600); + for (name, text) in entries { + writer + .start_file(name.as_str(), options) + .map_err(|err| zip_error(path, err))?; + writer + .write_all(text.as_bytes()) + .map_err(|err| io_error(path, err))?; + } + writer.finish().map_err(|err| zip_error(path, err))?; + Ok(()) +} + +fn io_error(path: &Path, err: std::io::Error) -> String { + format!("Could not write {} ({})", path.display(), err.kind()) +} + +fn zip_error(path: &Path, err: zip::result::ZipError) -> String { + format!("Could not write {} ({})", path.display(), err) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use std::io::Read; + use std::path::PathBuf; + use zip::ZipArchive; + + fn entries() -> Vec<(String, String)> { + vec![ + ( + "FR_192.0.2.0_24_2.txt".into(), + "192.0.2.10:8080:a:b\n192.0.2.11:8080:a:c\n".into(), + ), + ( + "XX_198.51.100.0_24_1.txt".into(), + "198.51.100.7:8080:d:e\n".into(), + ), + ( + "ALL_3.txt".into(), + "192.0.2.10:8080:a:b\n192.0.2.11:8080:a:c\n198.51.100.7:8080:d:e\n".into(), + ), + ] + } + + fn temp_path(extension: &str) -> PathBuf { + static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + std::env::temp_dir().join(format!( + "proxybench-archive-{}-{}-{}.{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(), + NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed), + extension + )) + } + + #[test] + fn write_zip_round_trips_every_named_entry() { + let path = temp_path("zip"); + write_zip(&path, &entries()).unwrap(); + let mut archive = ZipArchive::new(fs::File::open(&path).unwrap()).unwrap(); + assert_eq!(archive.len(), 3); + for (name, text) in entries() { + let mut stored = archive.by_name(&name).unwrap(); + let mut body = String::new(); + stored.read_to_string(&mut body).unwrap(); + assert_eq!(body, text); + } + let _ = fs::remove_file(path); + } + + #[test] + fn write_zip_overwrites_an_existing_archive() { + let path = temp_path("zip"); + let stale = vec![("old.txt".into(), "gone\n".into())]; + write_zip(&path, &stale).unwrap(); + write_zip(&path, &entries()).unwrap(); + let archive = ZipArchive::new(fs::File::open(&path).unwrap()).unwrap(); + assert_eq!(archive.len(), 3); + assert!(archive.file_names().all(|name| !name.eq("old.txt"))); + let _ = fs::remove_file(path); + } + + #[cfg(unix)] + #[test] + fn write_zip_keeps_owner_only_permissions() { + use std::os::unix::fs::PermissionsExt; + let path = temp_path("zip"); + write_zip(&path, &entries()).unwrap(); + let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o600); + let _ = fs::remove_file(path); + } +} diff --git a/src-tauri/src/export.rs b/src-tauri/src/export.rs index 24b06f9..c27bc9c 100644 --- a/src-tauri/src/export.rs +++ b/src-tauri/src/export.rs @@ -1,4 +1,5 @@ use std::fs; +use std::hash::{DefaultHasher, Hash, Hasher}; use std::path::Path; use crate::session::StoredBucket; @@ -10,8 +11,7 @@ pub fn filename(tags: &[Tag], country: Option<&str>, bucket: &StoredBucket) -> S pub fn category(tags: &[Tag], country: Option<&str>, bucket: &StoredBucket) -> String { let country = country - .filter(|code| code.len() == 2 && code.bytes().all(|b| b.is_ascii_alphabetic())) - .map(|code| code.to_ascii_uppercase()) + .and_then(valid_country) .unwrap_or_else(|| "XX".into()); let ip = bucket.subnet.network(); let qty = bucket.proxies.len(); @@ -21,6 +21,11 @@ pub fn category(tags: &[Tag], country: Option<&str>, bucket: &StoredBucket) -> S } } +fn valid_country(code: &str) -> Option { + (code.len() == 2 && code.bytes().all(|b| b.is_ascii_alphabetic())) + .then(|| code.to_ascii_uppercase()) +} + fn tag_stem(tags: &[Tag]) -> Option { let parts: Vec = tags .iter() @@ -49,29 +54,123 @@ fn sanitize(value: &str) -> String { out } +struct Bundle { + filename: String, + entries: Vec<(String, String)>, +} + +fn bundle(buckets: &[StoredBucket], tags: &Store) -> Result { + if buckets.is_empty() { + return Err("Import proxies before exporting.".into()); + } + let total: usize = buckets.iter().map(|bucket| bucket.proxies.len()).sum(); + let mut entries: Vec<(String, String)> = buckets + .iter() + .map(|bucket| { + ( + filename( + &tags.get(&bucket.subnet.cidr()), + bucket.country.as_deref(), + bucket, + ), + body(bucket), + ) + }) + .collect(); + entries.push(( + format!("ALL_{total}.txt"), + entries.iter().map(|(_, text)| text.as_str()).collect(), + )); + Ok(Bundle { + filename: bundle_filename(buckets, tags, total), + entries, + }) +} + +fn bundle_filename(buckets: &[StoredBucket], tags: &Store, total: usize) -> String { + format!( + "{}_{}subnets_{}proxies_{}.zip", + bundle_prefix(buckets, tags), + buckets.len(), + total, + fingerprint(buckets) + ) +} + +fn fingerprint(buckets: &[StoredBucket]) -> String { + let mut cidrs: Vec = buckets.iter().map(|b| b.subnet.cidr()).collect(); + cidrs.sort(); + let mut hasher = DefaultHasher::new(); + cidrs.hash(&mut hasher); + format!("{:016x}", hasher.finish()) +} + +fn bundle_prefix(buckets: &[StoredBucket], tags: &Store) -> String { + if let Some(stem) = uniform_tag_stem(buckets, tags) { + return stem; + } + if let Some(code) = uniform_country(buckets) { + return code; + } + "MIXED".into() +} + +fn uniform_tag_stem(buckets: &[StoredBucket], tags: &Store) -> Option { + let mut stems = buckets + .iter() + .map(|bucket| tag_stem(&tags.get(&bucket.subnet.cidr()))); + let stem = stems.next()?.as_ref()?.clone(); + stems + .all(|other| other.as_deref() == Some(stem.as_str())) + .then_some(stem) +} + +fn uniform_country(buckets: &[StoredBucket]) -> Option { + let mut codes = buckets + .iter() + .map(|bucket| bucket.country.as_deref().and_then(valid_country)); + let first = codes.next()?; + if !codes.all(|code| code == first) { + return None; + } + Some(first.unwrap_or_else(|| "XX".into())) +} + pub fn write_dir(dir: &Path, buckets: &[StoredBucket], tags: &Store) -> Result { if buckets.is_empty() { return Err("Import proxies before exporting.".into()); } fs::create_dir_all(dir).map_err(|err| io_error(dir, err))?; - let mut written = 0; - for bucket in buckets { - let cidr = bucket.subnet.cidr(); - let name = filename(&tags.get(&cidr), bucket.country.as_deref(), bucket); - let path = dir.join(&name); - write_file(&path, bucket)?; - written += 1; + match buckets { + [single] => { + let path = dir.join(filename( + &tags.get(&single.subnet.cidr()), + single.country.as_deref(), + single, + )); + write_text(&path, &body(single))?; + Ok(1) + } + many => { + let bundle = bundle(many, tags)?; + let path = dir.join(&bundle.filename); + crate::archive::write_zip(&path, &bundle.entries)?; + Ok(1) + } } - Ok(written) } -fn write_file(path: &Path, bucket: &StoredBucket) -> Result<(), String> { - let mut body = String::new(); +fn body(bucket: &StoredBucket) -> String { + let mut text = String::new(); for proxy in &bucket.proxies { - body.push_str(&proxy.source); - body.push('\n'); + text.push_str(&proxy.source); + text.push('\n'); } - crate::secure_file::write(path, body.as_bytes()).map_err(|err| io_error(path, err)) + text +} + +fn write_text(path: &Path, text: &str) -> Result<(), String> { + crate::secure_file::write(path, text.as_bytes()).map_err(|err| io_error(path, err)) } fn io_error(path: &Path, err: std::io::Error) -> String { @@ -81,7 +180,9 @@ fn io_error(path: &Path, err: std::io::Error) -> String { #[cfg(test)] mod tests { use super::*; + use std::io::{Cursor, Read}; use std::path::PathBuf; + use zip::ZipArchive; use crate::parse::ProxyLine; use crate::split::Subnet; @@ -230,4 +331,148 @@ mod tests { assert_eq!(mode, 0o600); let _ = fs::remove_dir_all(&dir); } + + #[test] + fn bundle_names_uniform_tagged_scopes_after_the_shared_tag_stem() { + let dir = temp_dir(); + let mut store = Store::load(dir.join("tags.json")).unwrap(); + store.set("192.0.2.0/24".into(), tags(&["isp"])).unwrap(); + store.set("198.51.100.0/24".into(), tags(&["isp"])).unwrap(); + let bundle = bundle( + &[bucket("192.0.2.10", 2), bucket("198.51.100.2", 3)], + &store, + ) + .unwrap(); + assert!(bundle.filename.starts_with("isp_2subnets_5proxies_")); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn bundle_names_partially_known_countries_as_mixed() { + let dir = temp_dir(); + let store = Store::load(dir.join("tags.json")).unwrap(); + let mut first = bucket("192.0.2.10", 1); + first.country = Some("FR".into()); + let mut second = bucket("198.51.100.2", 1); + second.country = None; + let bundle = bundle(&[first, second], &store).unwrap(); + assert!(bundle.filename.starts_with("MIXED_2subnets_2proxies_")); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn bundle_names_country_less_scopes_xx() { + let dir = temp_dir(); + let store = Store::load(dir.join("tags.json")).unwrap(); + let mut first = bucket("192.0.2.10", 1); + first.country = None; + let mut second = bucket("198.51.100.2", 2); + second.country = None; + let bundle = bundle(&[first, second], &store).unwrap(); + assert!(bundle.filename.starts_with("XX_2subnets_3proxies_")); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn bundle_collects_per_subnet_entries_plus_the_combined_import_list() { + let dir = temp_dir(); + let store = Store::load(dir.join("tags.json")).unwrap(); + let bundle = bundle( + &[bucket("192.0.2.10", 2), bucket("198.51.100.2", 1)], + &store, + ) + .unwrap(); + assert_eq!(bundle.entries.len(), 3); + assert_eq!( + bundle.entries[0], + ( + "FR_192.0.2.0_24_2.txt".into(), + "192.0.2.10:8080:user:0\n192.0.2.10:8080:user:1\n".into() + ) + ); + assert_eq!( + bundle.entries[1], + ( + "FR_198.51.100.0_24_1.txt".into(), + "198.51.100.2:8080:user:0\n".into() + ) + ); + assert_eq!(bundle.entries[2].0, "ALL_3.txt"); + assert_eq!( + bundle.entries[2].1, + format!("{}{}", bundle.entries[0].1, bundle.entries[1].1) + ); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn bundle_filenames_distinguish_distinct_scopes_sharing_the_same_aggregates() { + let dir = temp_dir(); + let store = Store::load(dir.join("tags.json")).unwrap(); + let first = bundle( + &[bucket("192.0.2.10", 3), bucket("198.51.100.2", 3)], + &store, + ) + .unwrap(); + let second = bundle( + &[bucket("192.0.2.10", 1), bucket("203.0.113.10", 5)], + &store, + ) + .unwrap(); + assert!(first.filename.starts_with("FR_2subnets_6proxies_")); + assert!(second.filename.starts_with("FR_2subnets_6proxies_")); + assert_ne!(first.filename, second.filename); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn bundle_filenames_follow_the_scope_not_the_member_content() { + let dir = temp_dir(); + let store = Store::load(dir.join("tags.json")).unwrap(); + let first = bundle( + &[bucket("192.0.2.10", 2), bucket("198.51.100.2", 1)], + &store, + ) + .unwrap(); + let second = bundle( + &[bucket("192.0.2.10", 3), bucket("198.51.100.2", 0)], + &store, + ) + .unwrap(); + assert_eq!(first.filename, second.filename); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn write_dir_writes_a_single_bundle_zip_when_several_buckets_are_selected() { + let dir = temp_dir(); + let out = dir.join("out"); + let store = Store::load(dir.join("tags.json")).unwrap(); + let written = write_dir( + &out, + &[bucket("192.0.2.10", 1), bucket("198.51.100.2", 2)], + &store, + ) + .unwrap(); + assert_eq!(written, 1); + let produced: Vec = fs::read_dir(&out) + .unwrap() + .map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned()) + .collect(); + assert_eq!(produced.len(), 1); + let zip_name = produced.first().unwrap(); + assert!(zip_name.starts_with("FR_2subnets_3proxies_")); + assert!(zip_name.ends_with(".zip")); + let bytes = fs::read(out.join(zip_name)).unwrap(); + let mut archive = ZipArchive::new(Cursor::new(bytes)).unwrap(); + assert_eq!(archive.len(), 3); + let mut combined = archive.by_name("ALL_3.txt").unwrap(); + let mut body = String::new(); + combined.read_to_string(&mut body).unwrap(); + assert_eq!( + body, + "192.0.2.10:8080:user:0\n198.51.100.2:8080:user:0\n198.51.100.2:8080:user:1\n" + ); + let _ = fs::remove_dir_all(&dir); + } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 4647199..902bc81 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,3 +1,4 @@ +mod archive; mod aycd; mod commands; mod country; diff --git a/src-tauri/src/secure_file.rs b/src-tauri/src/secure_file.rs index eb8bdd8..b792618 100644 --- a/src-tauri/src/secure_file.rs +++ b/src-tauri/src/secure_file.rs @@ -5,14 +5,7 @@ pub fn write(path: &Path, bytes: &[u8]) -> std::io::Result<()> { #[cfg(unix)] { use std::io::Write; - use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; - let mut file = fs::OpenOptions::new() - .write(true) - .create(true) - .truncate(true) - .mode(0o600) - .open(path)?; - file.set_permissions(fs::Permissions::from_mode(0o600))?; + let mut file = open_truncating(path)?; file.write_all(bytes) } #[cfg(not(unix))] @@ -20,3 +13,25 @@ pub fn write(path: &Path, bytes: &[u8]) -> std::io::Result<()> { fs::write(path, bytes) } } + +#[cfg(unix)] +pub fn open_truncating(path: &Path) -> std::io::Result { + use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; + let file = fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .mode(0o600) + .open(path)?; + file.set_permissions(fs::Permissions::from_mode(0o600))?; + Ok(file) +} + +#[cfg(not(unix))] +pub fn open_truncating(path: &Path) -> std::io::Result { + fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .open(path) +} From b6877820d240db5428c177a6c09d935ae43610fc Mon Sep 17 00:00:00 2001 From: Mathious6 <91007976+Mathious6@users.noreply.github.com> Date: Mon, 7 Sep 2026 20:27:15 +0200 Subject: [PATCH 2/2] chore(release): bump version to 0.4.3 --- package.json | 2 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index 55bf863..02559e5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "proxybench", - "version": "0.4.2", + "version": "0.4.3", "description": "Split HTTP proxy lists by subnet and measure Connect and TTFB", "type": "module", "scripts": { diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 788189e..daed07e 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2789,7 +2789,7 @@ dependencies = [ [[package]] name = "proxybench" -version = "0.4.2" +version = "0.4.3" dependencies = [ "base64 0.22.1", "bytes", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 97e2fb0..1f82781 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "proxybench" -version = "0.4.2" +version = "0.4.3" description = "Split HTTP proxy lists by subnet and measure Connect and TTFB" edition = "2021" license = "MIT" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index ed4b4be..704e7ad 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "proxybench", - "version": "0.4.2", + "version": "0.4.3", "identifier": "com.mathious.proxybench", "build": { "beforeDevCommand": "bun run dev",