Merge #1178: Enforce per-address storage caps per IPv6 /64, /56 and /48 (@Silvertree2010)

This commit is contained in:
bflatastic
2026-10-24 09:18:28 +07:00
2 changed files with 104 additions and 14 deletions
+58
View File
@@ -164,6 +164,64 @@ fn caps_per_address_and_for_everyone() {
assert_eq!(store.publish_open(ip("198.51.100.8"), "No", &snapshot("No")).unwrap_err(), KEY_ONLY);
}
#[test]
fn caps_per_address_count_the_wider_ipv6_blocks() {
let dir = TempDir::new("block-caps");
let mut c = open(dir.path());
c.max_files_per_ip = 1;
c.publishes_per_hour = 1000;
let mut store = Store::open(dir.path(), c.store()).unwrap();
// A /48 keeps 4 times one address's files: /64s in different /56s of it.
for i in 0..4 {
store.publish_open(ip(&format!("2001:db8:1:{:x}::1", i << 8)), "One", &snapshot("One")).unwrap();
}
let e = store.publish_open(ip("2001:db8:1:400::1"), "Five", &snapshot("Five")).unwrap_err();
assert!(e.contains("files on this server already"), "{e}");
// A /56 keeps twice one address's files.
store.publish_open(ip("2001:db8:2:1::1"), "One", &snapshot("One")).unwrap();
store.publish_open(ip("2001:db8:2:2::1"), "Two", &snapshot("Two")).unwrap();
let e = store.publish_open(ip("2001:db8:2:3::1"), "Three", &snapshot("Three")).unwrap_err();
assert!(e.contains("files on this server already"), "{e}");
drop(store);
// Bytes: one address keeps one file's worth, so a /48 keeps 4 files.
let probe = TempDir::new("block-caps-probe");
let mut p = Store::open(probe.path(), open(probe.path()).store()).unwrap();
let size = p.publish_open(ip("203.0.113.1"), "One", &snapshot("One")).unwrap().0.bytes;
let dir = TempDir::new("block-caps-bytes");
let mut s = open(dir.path()).store();
s.hosting.max_files_per_ip = 100;
s.hosting.max_bytes_per_ip = size;
let mut store = Store::open(dir.path(), s).unwrap();
for i in 0..4 {
store.publish_open(ip(&format!("2001:db8:3:{:x}::1", i << 8)), "One", &snapshot("One")).unwrap();
}
let e = store.publish_open(ip("2001:db8:3:400::1"), "One", &snapshot("One")).unwrap_err();
assert!(e.contains("MiB it may keep"), "{e}");
}
#[test]
fn edits_count_the_wider_ipv6_blocks_too() {
use figcraft_collab_net::hub::Backend;
let probe = TempDir::new("block-room-probe");
let mut p = Store::open(probe.path(), open(probe.path()).store()).unwrap();
let size = p.publish_open(ip("203.0.113.1"), "One", &snapshot("One")).unwrap().0.bytes;
let dir = TempDir::new("block-room");
let mut s = open(dir.path()).store();
s.hosting.max_files_per_ip = 100;
// One address keeps 2.25 files' worth, so its /56 keeps 4.5.
s.hosting.max_bytes_per_ip = size * 2 + size / 4;
let mut store = Store::open(dir.path(), s).unwrap();
let mut ids = Vec::new();
for i in 0..4 {
ids.push(store.publish_open(ip(&format!("2001:db8:4:{i:x}::1")), "One", &snapshot("One")).unwrap().0.id);
}
let server = Backend::open(&mut store, &ids[0]).unwrap();
// Its own /64 has room for this, but its /56 doesn't.
let e = Backend::room(&mut store, &ids[0], &server, (size * 3 / 4) as usize).unwrap_err();
assert!(e.contains("have reached their"), "{e}");
Backend::room(&mut store, &ids[0], &server, (size / 4) as usize).unwrap();
}
#[test]
fn idle_files_published_without_the_key_expire() {
let dir = TempDir::new("expiry");
+46 -14
View File
@@ -42,7 +42,7 @@ use figcraft_collab::message::{
PluginReply, PluginReq, ReportInfo, Usage,
};
use figcraft_collab::{Change, Replica, Server, same_secret};
use figcraft_collab_net::hub::{Admission, Backend, Closing, Handled, OpenFile, ip_key};
use figcraft_collab_net::hub::{Admission, Backend, Closing, Handled, OpenFile, ip_blocks, ip_key};
use figcraft_collab_net::token;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
@@ -516,6 +516,34 @@ impl Store {
self.keep(name, snapshot, None, &|_| Ok(())).map(|(info, edit, view, _)| (info, edit, view))
}
/// For each block the address `ip` counts in ([`ip_blocks`]), how many times one address's
/// caps it gets, and the files and bytes kept by the files published from addresses in it. An
/// address that doesn't parse counts as itself only, ×1, by its exact text.
fn per_block(&self, ip: &str) -> Vec<(u32, usize, u64)> {
let blocks_of = |text: &str| text.parse::<IpAddr>().map(|a| ip_blocks(ip_key(a))).unwrap_or_default();
let kept: Vec<(&str, Vec<_>, u64)> = self
.files
.iter()
.filter_map(|(id, e)| {
let from = e.meta.ip.as_deref()?;
Some((from, blocks_of(from).into_iter().map(|(b, _)| b).collect(), self.bytes(id)))
})
.collect();
let sum = |mine: &mut dyn Iterator<Item = &(&str, Vec<_>, u64)>| mine.fold((0, 0u64), |(f, b), (_, _, n)| (f + 1, b.saturating_add(*n)));
let blocks = blocks_of(ip);
if blocks.is_empty() {
let (files, bytes) = sum(&mut kept.iter().filter(|(from, _, _)| *from == ip));
return vec![(1, files, bytes)];
}
blocks
.into_iter()
.map(|(block, times)| {
let (files, bytes) = sum(&mut kept.iter().filter(|(_, blocks, _)| blocks.contains(&block)));
(times, files, bytes)
})
.collect()
}
/// Keep a document published without the key from `ip`, within the caps on what one address
/// and everyone may keep: the file, its links and its owner token.
pub fn publish_open(&mut self, ip: IpAddr, name: &str, snapshot: &[Change]) -> Result<(FileInfo, NewLink, NewLink, String), String> {
@@ -527,19 +555,21 @@ impl Store {
if !self.abuse.allow(ip, Action::Publish, Instant::now()) {
return Err("too many files published from your address lately: try again in an hour".into());
}
let ip_text = ip.to_string();
let mine: Vec<&String> = self.files.iter().filter(|(_, e)| e.meta.ip.as_deref() == Some(ip_text.as_str())).map(|(id, _)| id).collect();
if mine.len() >= max_files {
return Err(format!(
"your address keeps {} files on this server already, the most it may: remove one (with its owner token) or wait until one expires",
mine.len()
));
let blocks = self.per_block(&ip.to_string());
for &(times, files, _) in &blocks {
if files >= max_files.saturating_mul(times as usize) {
return Err(format!(
"your address keeps {files} files on this server already, the most it may: remove one (with its owner token) or wait until one expires"
));
}
}
let used_ip: u64 = mine.iter().map(|id| self.bytes(id)).sum();
let used_all: u64 = self.files.keys().map(|id| self.bytes(id)).sum();
let fits = |n: u64| {
if used_ip.saturating_add(n) > max_ip {
return Err(format!("your address's files would take more than the {} MiB it may keep here: remove one first", max_ip >> 20));
for &(times, _, used) in &blocks {
let cap = max_ip.saturating_mul(u64::from(times));
if used.saturating_add(n) > cap {
return Err(format!("your address's files would take more than the {} MiB it may keep here: remove one first", cap >> 20));
}
}
if used_all.saturating_add(n) > max_all {
return Err("this server is full: it takes no more files for now (try again later, or another server)".into());
@@ -1092,9 +1122,11 @@ impl Store {
let h = &self.cfg.hosting;
let Some(ip) = self.files.get(file).filter(|e| e.meta.owner.is_some()).and_then(|e| e.meta.ip.clone()) else { return Ok(()) };
let (max_ip, max_all) = (h.max_bytes_per_ip, h.max_total_bytes);
let used_ip: u64 = self.files.iter().filter(|(_, e)| e.meta.ip.as_deref() == Some(ip.as_str())).map(|(id, _)| self.bytes(id)).sum();
if used_ip.saturating_add(n) > max_ip {
return Err(format!("the files published from this file's address have reached their {} MiB: that change wasn't kept", max_ip >> 20));
for (times, _, used) in self.per_block(&ip) {
let cap = max_ip.saturating_mul(u64::from(times));
if used.saturating_add(n) > cap {
return Err(format!("the files published from this file's address have reached their {} MiB: that change wasn't kept", cap >> 20));
}
}
let used_all: u64 = self.files.keys().map(|id| self.bytes(id)).sum();
if used_all.saturating_add(n) > max_all {