Marketplace: check blob sizes from the object header before loading (PR review #9)
GitTree::read_file now takes a cap and reads the object's size from its header first, so a blob over MAX_MANIFEST_BYTES / MAX_ITEM_BYTES is refused without being inflated, and the blob is taken rather than cloned. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -10,7 +10,7 @@
|
||||
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::marketplace::tree::{hex, EntryKind, TreeView};
|
||||
use crate::marketplace::tree::{describe_size, hex, EntryKind, ReadError, TreeView};
|
||||
use crate::models::marketplace::{is_valid_item_key, CatalogItem, ItemKind};
|
||||
|
||||
pub const MAX_ITEM_BYTES: u64 = 2 * 1024 * 1024;
|
||||
@@ -115,8 +115,9 @@ fn truncate_preview(text: &str) -> String {
|
||||
format!("{}\n…(truncated)", &text[..cut])
|
||||
}
|
||||
|
||||
fn read_utf8(tree: &dyn TreeView, path: &str) -> Result<Option<String>, String> {
|
||||
match tree.read_file(path)? {
|
||||
/// A UTF-8 file of at most `max_bytes` (checked before it is loaded).
|
||||
fn read_utf8(tree: &dyn TreeView, path: &str, max_bytes: u64) -> Result<Option<String>, String> {
|
||||
match tree.read_file(path, max_bytes)? {
|
||||
None => Ok(None),
|
||||
Some(bytes) => String::from_utf8(bytes)
|
||||
.map(Some)
|
||||
@@ -187,16 +188,9 @@ fn plugin_source_path(source: &serde_json::Value) -> Result<String, String> {
|
||||
}
|
||||
|
||||
fn read_plugin_catalog(tree: &dyn TreeView) -> Result<Option<Vec<serde_json::Value>>, String> {
|
||||
let Some(text) = read_utf8(tree, PLUGIN_CATALOG_PATH)? else {
|
||||
let Some(text) = read_utf8(tree, PLUGIN_CATALOG_PATH, MAX_MANIFEST_BYTES)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
if text.len() as u64 > MAX_MANIFEST_BYTES {
|
||||
return Err(format!(
|
||||
"{} is larger than {} MiB",
|
||||
PLUGIN_CATALOG_PATH,
|
||||
MAX_MANIFEST_BYTES / (1024 * 1024)
|
||||
));
|
||||
}
|
||||
let json: serde_json::Value = serde_json::from_str(&text)
|
||||
.map_err(|e| format!("{} is not valid JSON: {}", PLUGIN_CATALOG_PATH, e))?;
|
||||
let plugins = json
|
||||
@@ -300,16 +294,19 @@ fn collect_dir(
|
||||
if *entries_seen > MAX_ITEM_FILES {
|
||||
return Err(format!("has more than {} files", MAX_ITEM_FILES));
|
||||
}
|
||||
let data = tree
|
||||
.read_file(&format!("{}/{}", root, child_rel))?
|
||||
.ok_or_else(|| format!("{} vanished while reading", child_rel))?;
|
||||
// Capped at what is left of the item's budget, so no file
|
||||
// bigger than the whole item allows is ever loaded.
|
||||
let data = match tree
|
||||
.read_file(&format!("{}/{}", root, child_rel), MAX_ITEM_BYTES - *total)
|
||||
{
|
||||
Ok(Some(data)) => data,
|
||||
Ok(None) => return Err(format!("{} vanished while reading", child_rel)),
|
||||
Err(ReadError::TooLarge { .. }) => {
|
||||
return Err(format!("is larger than {}", describe_size(MAX_ITEM_BYTES)))
|
||||
}
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
*total += data.len() as u64;
|
||||
if *total > MAX_ITEM_BYTES {
|
||||
return Err(format!(
|
||||
"is larger than {} MiB",
|
||||
MAX_ITEM_BYTES / (1024 * 1024)
|
||||
));
|
||||
}
|
||||
out.push(ItemFile {
|
||||
rel_path: child_rel,
|
||||
data,
|
||||
@@ -348,14 +345,8 @@ pub fn item_files(tree: &dyn TreeView, kind: ItemKind, key: &str) -> Result<Vec<
|
||||
_ => return Err(format!("{} is not a regular file", path)),
|
||||
}
|
||||
let data = tree
|
||||
.read_file(&path)?
|
||||
.read_file(&path, MAX_ITEM_BYTES)?
|
||||
.ok_or_else(|| format!("{} is missing", path))?;
|
||||
if data.len() as u64 > MAX_ITEM_BYTES {
|
||||
return Err(format!(
|
||||
"is larger than {} MiB",
|
||||
MAX_ITEM_BYTES / (1024 * 1024)
|
||||
));
|
||||
}
|
||||
Ok(vec![ItemFile {
|
||||
rel_path: format!("{}.md", key),
|
||||
data,
|
||||
@@ -485,14 +476,8 @@ fn validate_hooks(hooks: &serde_json::Value) -> Result<Vec<String>, String> {
|
||||
|
||||
fn read_hook_json(tree: &dyn TreeView, key: &str) -> Result<serde_json::Value, String> {
|
||||
let path = format!("hooks/{}/hook.json", key);
|
||||
let text = read_utf8(tree, &path)?.ok_or_else(|| format!("{} is missing", path))?;
|
||||
if text.len() as u64 > MAX_MANIFEST_BYTES {
|
||||
return Err(format!(
|
||||
"{} is larger than {} MiB",
|
||||
path,
|
||||
MAX_MANIFEST_BYTES / (1024 * 1024)
|
||||
));
|
||||
}
|
||||
let text = read_utf8(tree, &path, MAX_MANIFEST_BYTES)?
|
||||
.ok_or_else(|| format!("{} is missing", path))?;
|
||||
serde_json::from_str(&text).map_err(|e| format!("{} is not valid JSON: {}", path, e))
|
||||
}
|
||||
|
||||
@@ -581,7 +566,7 @@ fn parse_single_files(
|
||||
continue;
|
||||
}
|
||||
match entry.kind {
|
||||
EntryKind::File => match read_utf8(tree, &it.path) {
|
||||
EntryKind::File => match read_utf8(tree, &it.path, MAX_ITEM_BYTES) {
|
||||
Ok(Some(text)) => describe_markdown(&mut it, &text, kind == ItemKind::Command),
|
||||
Ok(None) => it.invalid = Some(format!("{} is missing", it.path)),
|
||||
Err(e) => it.invalid = Some(e),
|
||||
@@ -623,11 +608,13 @@ fn parse_folders(tree: &dyn TreeView, kind: ItemKind, folder: &str, out: &mut Ve
|
||||
continue;
|
||||
}
|
||||
match kind {
|
||||
ItemKind::Skill => match read_utf8(tree, &format!("{}/SKILL.md", it.path)) {
|
||||
Ok(Some(text)) => describe_markdown(&mut it, &text, false),
|
||||
Ok(None) => it.invalid = Some(format!("{} has no SKILL.md", it.path)),
|
||||
Err(e) => it.invalid = Some(e),
|
||||
},
|
||||
ItemKind::Skill => {
|
||||
match read_utf8(tree, &format!("{}/SKILL.md", it.path), MAX_ITEM_BYTES) {
|
||||
Ok(Some(text)) => describe_markdown(&mut it, &text, false),
|
||||
Ok(None) => it.invalid = Some(format!("{} has no SKILL.md", it.path)),
|
||||
Err(e) => it.invalid = Some(e),
|
||||
}
|
||||
}
|
||||
ItemKind::Hook => match read_hook_json(tree, &entry.name) {
|
||||
Ok(json) => {
|
||||
if let Some(name) = json
|
||||
|
||||
Reference in New Issue
Block a user