206 lines
7.5 KiB
Rust
206 lines
7.5 KiB
Rust
//! Session-owned storage for large pasted-input artifacts.
|
|
|
|
use std::fs;
|
|
use std::io::Write as _;
|
|
use std::path::Path;
|
|
use std::time::{SystemTime, UNIX_EPOCH};
|
|
|
|
use fs4::fs_std::FileExt;
|
|
use protocol::{PasteArtifactAvailability, PasteArtifactMediaType, PasteArtifactRef};
|
|
use serde::{Deserialize, Serialize};
|
|
use sha2::{Digest, Sha256};
|
|
|
|
use crate::StoreError;
|
|
|
|
/// Bounded storage policy applied before a large paste becomes durable input.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub struct PasteArtifactLimits {
|
|
pub max_artifact_bytes: u64,
|
|
pub max_session_bytes: u64,
|
|
pub max_session_artifacts: u64,
|
|
}
|
|
|
|
impl Default for PasteArtifactLimits {
|
|
fn default() -> Self {
|
|
Self {
|
|
max_artifact_bytes: 8 * 1024 * 1024,
|
|
max_session_bytes: 64 * 1024 * 1024,
|
|
max_session_artifacts: 1_024,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Integrity-bearing on-disk record. The body and metadata are committed in one
|
|
/// atomic file replacement so readers never observe a half-written artifact.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub(crate) struct StoredPasteArtifact {
|
|
pub reference: PasteArtifactRef,
|
|
pub content: String,
|
|
}
|
|
|
|
pub(crate) fn stored_paste_usage(artifact_dir: &Path) -> Result<(u64, u64), StoreError> {
|
|
if !artifact_dir.exists() {
|
|
return Ok((0, 0));
|
|
}
|
|
let mut aggregate = 0_u64;
|
|
let mut artifact_count = 0_u64;
|
|
for entry in fs::read_dir(artifact_dir)? {
|
|
let path = entry?.path();
|
|
let Some(name) = path.file_name().and_then(|value| value.to_str()) else {
|
|
continue;
|
|
};
|
|
if !name.ends_with(".json") || name.ends_with(".file.json") {
|
|
continue;
|
|
}
|
|
let stored: StoredPasteArtifact = serde_json::from_slice(&fs::read(&path)?)?;
|
|
verify(&stored, &stored.reference.artifact_id)?;
|
|
artifact_count = artifact_count.checked_add(1).ok_or_else(|| {
|
|
StoreError::PasteArtifactLimit("session artifact count overflow".to_string())
|
|
})?;
|
|
aggregate = aggregate
|
|
.checked_add(stored.reference.byte_len)
|
|
.ok_or_else(|| {
|
|
StoreError::PasteArtifactLimit("session aggregate size overflow".to_string())
|
|
})?;
|
|
}
|
|
Ok((aggregate, artifact_count))
|
|
}
|
|
|
|
pub(crate) fn write_to_dir(
|
|
artifact_dir: &Path,
|
|
source_entry_id: &str,
|
|
content: &str,
|
|
limits: PasteArtifactLimits,
|
|
) -> Result<PasteArtifactRef, StoreError> {
|
|
let byte_len = content.len() as u64;
|
|
if byte_len > limits.max_artifact_bytes {
|
|
return Err(StoreError::PasteArtifactLimit(format!(
|
|
"artifact has {byte_len} bytes; maximum is {}",
|
|
limits.max_artifact_bytes
|
|
)));
|
|
}
|
|
fs::create_dir_all(artifact_dir)?;
|
|
let aggregate_lock = fs::OpenOptions::new()
|
|
.create(true)
|
|
.read(true)
|
|
.write(true)
|
|
.open(artifact_dir.join(".aggregate.lock"))?;
|
|
FileExt::lock_exclusive(&aggregate_lock)?;
|
|
let (paste_bytes, artifact_count) = stored_paste_usage(artifact_dir)?;
|
|
let (uploaded_bytes, uploaded_count) =
|
|
crate::uploaded_file::stored_uploaded_file_usage(artifact_dir)?;
|
|
let aggregate = paste_bytes.checked_add(uploaded_bytes).ok_or_else(|| {
|
|
StoreError::PasteArtifactLimit("session aggregate size overflow".to_string())
|
|
})?;
|
|
let artifact_count = artifact_count.checked_add(uploaded_count).ok_or_else(|| {
|
|
StoreError::PasteArtifactLimit("session artifact count overflow".to_string())
|
|
})?;
|
|
let projected = aggregate.checked_add(byte_len).ok_or_else(|| {
|
|
StoreError::PasteArtifactLimit("session aggregate size overflow".to_string())
|
|
})?;
|
|
if projected > limits.max_session_bytes {
|
|
return Err(StoreError::PasteArtifactLimit(format!(
|
|
"session artifacts would use {projected} bytes; maximum is {}",
|
|
limits.max_session_bytes
|
|
)));
|
|
}
|
|
if artifact_count >= limits.max_session_artifacts {
|
|
return Err(StoreError::PasteArtifactLimit(format!(
|
|
"session already has {artifact_count} artifacts; maximum is {}",
|
|
limits.max_session_artifacts
|
|
)));
|
|
}
|
|
|
|
let artifact_id = uuid::Uuid::now_v7().to_string();
|
|
let created_at_ms = SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.map_err(|error| StoreError::PasteArtifactIntegrity(error.to_string()))?
|
|
.as_millis() as u64;
|
|
let reference = PasteArtifactRef {
|
|
artifact_id: artifact_id.clone(),
|
|
created_at_ms,
|
|
media_type: PasteArtifactMediaType::TextPlainUtf8,
|
|
availability: PasteArtifactAvailability::Available,
|
|
byte_len,
|
|
char_count: content.chars().count() as u64,
|
|
line_count: line_count(content),
|
|
sha256: sha256_hex(content),
|
|
source_entry_id: source_entry_id.to_string(),
|
|
};
|
|
let bytes = serde_json::to_vec(&StoredPasteArtifact {
|
|
reference: reference.clone(),
|
|
content: content.to_string(),
|
|
})?;
|
|
let target = artifact_dir.join(format!("{artifact_id}.json"));
|
|
let temporary = artifact_dir.join(format!(".{artifact_id}.tmp"));
|
|
let mut file = fs::OpenOptions::new()
|
|
.create_new(true)
|
|
.write(true)
|
|
.open(&temporary)?;
|
|
if let Err(error) = file.write_all(&bytes).and_then(|_| file.sync_all()) {
|
|
let _ = fs::remove_file(&temporary);
|
|
return Err(error.into());
|
|
}
|
|
if let Err(error) = fs::rename(&temporary, &target) {
|
|
let _ = fs::remove_file(&temporary);
|
|
return Err(error.into());
|
|
}
|
|
if let Ok(directory) = fs::File::open(artifact_dir) {
|
|
directory.sync_all()?;
|
|
}
|
|
Ok(reference)
|
|
}
|
|
|
|
pub(crate) fn read_from_dir(
|
|
artifact_dir: &Path,
|
|
artifact_id: &str,
|
|
) -> Result<(PasteArtifactRef, String), StoreError> {
|
|
let parsed = uuid::Uuid::parse_str(artifact_id)
|
|
.map_err(|_| StoreError::PasteArtifactNotFound(artifact_id.to_string()))?;
|
|
if parsed.to_string() != artifact_id {
|
|
return Err(StoreError::PasteArtifactNotFound(artifact_id.to_string()));
|
|
}
|
|
let path = artifact_dir.join(format!("{artifact_id}.json"));
|
|
let bytes = match fs::read(path) {
|
|
Ok(bytes) => bytes,
|
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
|
return Err(StoreError::PasteArtifactNotFound(artifact_id.to_string()));
|
|
}
|
|
Err(error) => return Err(error.into()),
|
|
};
|
|
let stored: StoredPasteArtifact = serde_json::from_slice(&bytes)?;
|
|
verify(&stored, artifact_id)?;
|
|
Ok((stored.reference, stored.content))
|
|
}
|
|
|
|
fn verify(stored: &StoredPasteArtifact, artifact_id: &str) -> Result<(), StoreError> {
|
|
let actual_digest = sha256_hex(&stored.content);
|
|
if stored.reference.artifact_id != artifact_id
|
|
|| stored.reference.created_at_ms == 0
|
|
|| stored.reference.media_type != PasteArtifactMediaType::TextPlainUtf8
|
|
|| stored.reference.availability != PasteArtifactAvailability::Available
|
|
|| stored.reference.byte_len != stored.content.len() as u64
|
|
|| stored.reference.char_count != stored.content.chars().count() as u64
|
|
|| stored.reference.line_count != line_count(&stored.content)
|
|
|| stored.reference.sha256 != actual_digest
|
|
{
|
|
return Err(StoreError::PasteArtifactIntegrity(artifact_id.to_string()));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn sha256_hex(content: &str) -> String {
|
|
Sha256::digest(content.as_bytes())
|
|
.iter()
|
|
.map(|byte| format!("{byte:02x}"))
|
|
.collect()
|
|
}
|
|
|
|
fn line_count(content: &str) -> u64 {
|
|
if content.is_empty() {
|
|
0
|
|
} else {
|
|
content.lines().count().max(1) as u64
|
|
}
|
|
}
|