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:
2026-09-27 13:01:48 -07:00
co-authored by Claude Opus 5.5
parent f2bb092586
commit 14852ead65
3 changed files with 159 additions and 51 deletions
+28 -41
View File
@@ -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