Check folder inventory evidence with the scope it was recorded with (#461)

core_archive_show records max_entries and max_total_bytes, and a shared recorded_inventory helper checks a folder's freshness at the scope it was recorded with, so core_archives_compare and the Android, Apple and Harmony graph tools no longer refuse unchanged folders as file_changed.
This commit is contained in:
NetterSilver
2026-11-02 19:43:05 +01:00
committed by GitHub
parent d4b20027b9
commit b11554d7cc
2 changed files with 90 additions and 16 deletions
+3 -7
View File
@@ -3,7 +3,7 @@
//! and coverage, and platform projections of artifact inventories. Pure computation over
//! evidence (or over a path, analysed on the spot).
use super::artifact_tools::{WalkOptions, abs_path, env, inventory_path, is_path_input, resolve_evidence_input, walk_dir};
use super::artifact_tools::{WalkOptions, abs_path, env, inventory_path, is_path_input, recorded_inventory, resolve_evidence_input, walk_dir};
use super::js_tools::load_graph;
use crate::tool::{Ctx, Effects, Requires, ToolOutput, ToolSpec, Toolset, parse_params, schema_of};
use craft_anything_analysis::js_inventory::{InvLimits, Inventory};
@@ -11,7 +11,7 @@ use craft_anything_analysis::js_ledger::{Binding, Review, build_ledger, evaluate
use craft_anything_analysis::js_source::{SourceGraph, build_source_graph, diff_source_and_bundle};
use craft_anything_analysis::js_trace::{Direction, SeedKind, find_seeds, follow_feature, trace_semantics};
use craft_anything_analysis::{js_compare, js_projection};
use craft_anything_evidence::{Confidence, Coverage, CoverageStatus, ErrorCode, EvidenceBuilder, Limitation, Location, Subject, ToolError};
use craft_anything_evidence::{Confidence, Coverage, CoverageStatus, EvidenceBuilder, Limitation, Location, Subject, ToolError};
use schemars::JsonSchema;
use serde::Deserialize;
use serde_json::{Value, json};
@@ -716,11 +716,7 @@ fn projection_inventory(ctx: &Ctx<'_>, p: &ProjectionParams) -> Result<(Inventor
let limits = InvLimits { max_depth: 2, ..InvLimits::default() };
if let Some(v) = &p.inventory {
let ev = resolve_evidence_input(ctx, v, &["core_archive_show"])?;
let path = ev.params.get("path").and_then(Value::as_str).ok_or_else(|| ToolError::invalid("inventory evidence has no path"))?;
let (inv, subject) = inventory_path(std::path::Path::new(path), limits, &ctx.cancel)?;
if subject.sha256 != ev.subject.sha256 {
return Err(ToolError::new(ErrorCode::FileChanged, format!("{path} changed since evidence {} was recorded", ev.id)));
}
let (inv, subject) = recorded_inventory(&ev, limits, &ctx.cancel)?;
return Ok((inv, subject, Some(ev.id)));
}
let path = match &p.path {
+87 -9
View File
@@ -471,11 +471,45 @@ fn integrity_error(inv: &Inventory) -> ToolError {
.with_details(json!({"contradictions": inv.contradictions.iter().take(50).collect::<Vec<_>>()}))
}
fn show_limits(max_depth: u32, max_entries: usize, max_total_bytes: u64) -> InvLimits {
InvLimits { max_entries: max_entries.clamp(1, 1_000_000), max_depth: max_depth.min(6), max_total_bytes, ..InvLimits::default() }
}
/// Re-read the path a core_archive_show record names and check it is unchanged, then return its inventory at `limits`.
/// The check uses the scope the record was made with, since a folder's id covers only the entries that scope lists.
pub(crate) fn recorded_inventory(ev: &Evidence, limits: InvLimits, cancel: &CancelToken) -> Result<(Inventory, Subject), ToolError> {
let path = ev.params.get("path").and_then(Value::as_str).ok_or_else(|| ToolError::invalid("inventory evidence has no path"))?;
let num = |k: &str| ev.params.get(k).and_then(Value::as_u64);
let recorded = show_limits(
num("max_depth").map_or(default_depth(), |v| u32::try_from(v).unwrap_or(u32::MAX)),
num("max_entries").map_or(default_max_entries(), |v| usize::try_from(v).unwrap_or(usize::MAX)),
num("max_total_bytes").unwrap_or_else(default_max_total),
);
// A file's id is the hash of its bytes, whatever the scope; a folder's depends on the scope.
let check = if ev.subject.kind == "directory" { recorded } else { limits };
let (inv, subject) = inventory_path(Path::new(path), check, cancel)?;
if subject.sha256 != ev.subject.sha256 {
return Err(ToolError::new(ErrorCode::FileChanged, format!("{path} changed since evidence {} was recorded", ev.id))
.with_remediation("Run core_archive_show again, or pass the path directly."));
}
if same_limits(&check, &limits) {
return Ok((inv, subject));
}
inventory_path(Path::new(path), limits, cancel)
}
fn same_limits(a: &InvLimits, b: &InvLimits) -> bool {
a.max_entries == b.max_entries
&& a.max_depth == b.max_depth
&& a.max_nested_bytes == b.max_nested_bytes
&& a.max_entry_bytes == b.max_entry_bytes
&& a.max_total_bytes == b.max_total_bytes
}
fn core_archive_show(ctx: &mut Ctx<'_>, params: Value) -> Result<ToolOutput, ToolError> {
let p: InspectArtifactParams = parse_params(params)?;
let path = target_path(ctx, p.path.as_deref())?;
let limits =
InvLimits { max_entries: p.max_entries.clamp(1, 1_000_000), max_depth: p.max_depth.min(6), max_total_bytes: p.max_total_bytes, ..InvLimits::default() };
let limits = show_limits(p.max_depth, p.max_entries, p.max_total_bytes);
ctx.report(None, format!("Inventorying {}", path.display()));
let (inv, subject) = inventory_path(&path, limits, &ctx.cancel)?;
let blocking = inv.contradictions.iter().any(|c| c.get("reason").and_then(Value::as_str).is_some_and(|r| r.contains("integrity") || r.contains("CRC")));
@@ -505,7 +539,7 @@ fn core_archive_show(ctx: &mut Ctx<'_>, params: Value) -> Result<ToolOutput, Too
}
lims.push(Limitation::new("no_disk_images", "Disk images (DMG), installers (PKG/MSI) and compressed tarballs are listed as single files, not opened."));
let ev = EvidenceBuilder::new("core_archive_show", subject, env(ctx))
.params(json!({"path": path, "if_tampered": p.if_tampered == IntegrityPolicy::Fail, "offset": p.offset, "limit": limit, "max_depth": limits.max_depth}))
.params(json!({"path": path, "if_tampered": p.if_tampered == IntegrityPolicy::Fail, "offset": p.offset, "limit": limit, "max_depth": limits.max_depth, "max_entries": limits.max_entries, "max_total_bytes": limits.max_total_bytes}))
.result(json!({
"path": path,
"root": inv.root, "root_kind": inv.root_kind, "content_id": inv.content_id(),
@@ -705,12 +739,7 @@ fn inventory_input(ctx: &Ctx<'_>, v: &Value) -> Result<(Inventory, Option<String
}
let ev = resolve_evidence_input(ctx, v, &["core_archive_show"])?;
// Evidence holds one page; re-read the recorded path for the full inventory and check it is unchanged.
let path = ev.params.get("path").and_then(Value::as_str).ok_or_else(|| ToolError::invalid("inventory evidence has no path"))?;
let (inv, subject) = inventory_path(Path::new(path), InvLimits::default(), &ctx.cancel)?;
if subject.sha256 != ev.subject.sha256 {
return Err(ToolError::new(ErrorCode::FileChanged, format!("{path} changed since evidence {} was recorded", ev.id))
.with_remediation("Run core_archive_show again, or pass the path directly."));
}
let (inv, _) = recorded_inventory(&ev, InvLimits::default(), &ctx.cancel)?;
Ok((inv, Some(ev.id)))
}
@@ -803,6 +832,55 @@ mod tests {
assert!(call(&e, "core_archives_compare", json!({"left": "relative/path", "right": b})).is_err());
}
#[test]
fn folder_evidence_scope_is_not_a_change() {
let dir = tempfile::tempdir().unwrap();
let app = dir.path().join("app");
std::fs::create_dir(&app).unwrap();
let l3 = fx::zip_stored(&[("leaf.txt", b"leaf")]);
let l2 = fx::zip_stored(&[("l3.zip", &l3)]);
let l1 = fx::zip_stored(&[("l2.zip", &l2)]);
std::fs::write(app.join("l1.zip"), &l1).unwrap();
std::fs::write(app.join("other.txt"), b"other").unwrap();
let e = Engine::new(crate::testing::session());
let tools = ["core_archives_compare", "android_app_graph_build", "apple_app_graph_build", "harmony_app_graph_build"];
let consume = |tool: &str, input: Value| {
let p = if tool == "core_archives_compare" { json!({"left": input, "right": app}) } else { json!({"inventory": input}) };
call(&e, tool, p)
};
let scopes = [json!({"max_depth": 2}), json!({"max_depth": 3}), json!({"max_entries": 2}), json!({"max_total_bytes": 10})];
let mut recorded = Vec::new();
for scope in scopes {
let mut p = scope.clone();
p["path"] = json!(app);
let inv = call(&e, "core_archive_show", p).unwrap();
let id = inv["id"].as_str().unwrap().to_string();
for tool in tools {
for input in [json!({"kind": "saved", "evidence_id": id}), inv.clone()] {
let out = consume(tool, input).unwrap_or_else(|err| panic!("{scope} {tool}: {err:?}"));
assert!(out["links"].as_array().unwrap().contains(&json!(id)), "{scope} {tool}");
if tool == "core_archives_compare" {
for k in ["added", "removed", "changed", "moved"] {
assert_eq!(out["result"]["counts"][k], 0, "{scope} {k}");
}
}
}
}
recorded.push((id, inv));
}
// Real changes are still refused (both files change, so each recorded scope covers one).
std::fs::write(app.join("l1.zip"), fx::zip_stored(&[("l2.zip", b"changed")])).unwrap();
std::fs::write(app.join("other.txt"), b"changed").unwrap();
for (id, inv) in recorded {
for tool in tools {
for input in [json!({"kind": "saved", "evidence_id": id}), inv.clone()] {
let err = consume(tool, input).unwrap_err();
assert_eq!(err.code, ErrorCode::FileChanged, "{tool}");
}
}
}
}
#[test]
fn if_tampered() {
let dir = tempfile::tempdir().unwrap();