feat: store large paste inputs as artifacts
This commit is contained in:
@@ -0,0 +1,156 @@
|
||||
//! Session-owned storage for large pasted-input artifacts.
|
||||
|
||||
use std::fs;
|
||||
use std::io::Write as _;
|
||||
use std::path::Path;
|
||||
|
||||
use protocol::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,
|
||||
}
|
||||
|
||||
impl Default for PasteArtifactLimits {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_artifact_bytes: 8 * 1024 * 1024,
|
||||
max_session_bytes: 64 * 1024 * 1024,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 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 mut aggregate = 0_u64;
|
||||
for entry in fs::read_dir(artifact_dir)? {
|
||||
let path = entry?.path();
|
||||
if path.extension().and_then(|value| value.to_str()) != Some("json") {
|
||||
continue;
|
||||
}
|
||||
let stored: StoredPasteArtifact = serde_json::from_slice(&fs::read(&path)?)?;
|
||||
verify(&stored, &stored.reference.artifact_id)?;
|
||||
aggregate = aggregate
|
||||
.checked_add(stored.reference.byte_len)
|
||||
.ok_or_else(|| {
|
||||
StoreError::PasteArtifactLimit("session aggregate size 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
|
||||
)));
|
||||
}
|
||||
|
||||
let artifact_id = uuid::Uuid::now_v7().to_string();
|
||||
let reference = PasteArtifactRef {
|
||||
artifact_id: artifact_id.clone(),
|
||||
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.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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user