Files
OneCAD/crates/io/src/design/thumbnail.rs
T
Kevin Ravensberg 96f6424aec fix: confine MCP agents' files to allowed folders; recovery discard deletes only recovery entries (#205) (#578)
Agent tool calls (MCP, and bridged control requests carrying allow_paths) reach files only in the open design's folder (strictly inside home or the temporary folder, not in a hidden folder, and never its hidden files), the app's recovery folder, the server's private scratch folder and each --allow-path; --allow-any-path lifts the limit. Paths are resolved first, so .. and symlinks that lead out (including a save through a link, #845) are refused. On Windows, UNC, device, NT, verbatim non-drive, drive-relative, alternate-stream, reserved-device and 8.3 short-name paths are refused before any file system call, and the resolved path must be on a local drive. doc.recovery_discard and doc.recover {discard} delete only real recovery entries. Docs: mcp.md, control-protocol.md, README.
2026-10-13 18:24:08 -04:00

79 lines
3.5 KiB
Rust

//! Portable, disposable PNG pictures. Reading never resolves source blobs.
use super::*;
pub const THUMBNAIL_SIZE: [usize; 2] = [240, 150];
const MAX_BYTES: usize = 256 << 10;
const FORMAT: &str = "solidcraft-thumbnail/1";
fn valid_header(bytes: &[u8]) -> bool {
bytes.len() <= MAX_BYTES
&& bytes.get(..8) == Some(b"\x89PNG\r\n\x1a\n")
&& bytes.get(8..16) == Some(b"\0\0\0\rIHDR")
&& bytes.get(16..20) == Some((THUMBNAIL_SIZE[0] as u32).to_be_bytes().as_slice())
&& bytes.get(20..24) == Some((THUMBNAIL_SIZE[1] as u32).to_be_bytes().as_slice())
}
pub(super) fn write(writer: &mut PackageWriter, png: &[u8], document: &[u8]) -> Result<()> {
if !valid_header(png) {
return Ok(());
}
let key = serde_json::to_vec(&json!({"format": FORMAT, "app": env!("CARGO_PKG_VERSION"), "document": digest(document), "payload": digest(png)}))
.map_err(invalid)?;
if writer.names.len().saturating_add(2) > MAX_ENTRIES
|| writer
.expanded
.saturating_add(key.len())
.saturating_add(png.len())
.saturating_add(writer.names.len().saturating_mul(256))
.saturating_add(65_536)
> MAX_DESIGN_BYTES
{
return Ok(());
}
writer.part("cache/thumbnail.json", &key, false)?;
writer.part("cache/thumbnail.png", png, false)
}
pub(super) fn read<R: Read + Seek>(reader: &mut PackageReader<R>) -> Result<Vec<u8>> {
let container: Value = serde_json::from_slice(&reader.limited_part("container.json", 4096)?).map_err(invalid)?;
if container.get("format").and_then(Value::as_str) != Some(VERSION) {
return Err(invalid("thumbnail container version"));
}
let key: Value = serde_json::from_slice(&reader.limited_part("cache/thumbnail.json", 4096)?).map_err(invalid)?;
let document = reader.document(16 << 20)?;
if key.get("format").and_then(Value::as_str) != Some(FORMAT)
|| key.get("app").and_then(Value::as_str) != Some(env!("CARGO_PKG_VERSION"))
|| key.get("document").and_then(Value::as_str) != Some(digest(&document).as_str())
{
return Err(invalid("thumbnail key"));
}
let png = reader.limited_part("cache/thumbnail.png", MAX_BYTES)?;
if !valid_header(&png) || key.get("payload").and_then(Value::as_str) != Some(digest(&png).as_str()) {
return Err(invalid("thumbnail payload"));
}
// Consumers must also use a bounded PNG decoder; the header is not a decode.
Ok(png)
}
/// Optional PNG bytes, bounded and keyed to this exact document. Consumers must
/// decode with dimension/allocation limits; invalid pictures are cache misses.
pub fn read_design_thumbnail(bytes: &[u8]) -> Option<Vec<u8>> {
DesignContainerReader::new(Cursor::new(bytes)).ok()?.thumbnail()
}
/// Read a portable picture without importing or reading the source blobs.
pub fn read_design_thumbnail_file(path: &str) -> Option<Vec<u8>> {
#[cfg(not(target_arch = "wasm32"))]
let source = {
crate::vfs::check(path).ok()?;
let metadata = std::fs::metadata(path).ok()?;
if !metadata.is_file() || metadata.len() > MAX_DESIGN_BYTES as u64 {
return None;
}
let source = std::fs::File::open(path).ok()?;
let metadata = source.metadata().ok()?;
if !metadata.is_file() || metadata.len() > MAX_DESIGN_BYTES as u64 {
return None;
}
source
};
#[cfg(target_arch = "wasm32")]
let source = Cursor::new(crate::vfs::read(path, MAX_DESIGN_BYTES).ok()?);
DesignContainerReader::new(source).ok()?.thumbnail()
}