mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-22 02:56:52 +03:00
Fix: honor reviewed text styling at native finish
Preserve original comp scores and integrity gates; bind the additional text comparison to the private approved assembly and unchanged inputs. AI assistance: implemented and validated with OpenAI Codex.
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
//! Project-scoped capture transport. Approval still belongs to the shared gate.
|
||||
//! Host adapters must independently audit retained evidence before accepting a run.
|
||||
use crate::entry_capture::CdpEntryRenderer;
|
||||
use crate::reviewed_entry::ReviewedEntryRenderer;
|
||||
use base64::Engine;
|
||||
use impeccable_comp_verbs::entry_capture::{
|
||||
CapturedEntry, EntryRenderer, EntryRequest, EntryStage,
|
||||
@@ -98,6 +98,7 @@ pub fn serve(args: &[String]) -> Result<(), Box<dyn std::error::Error>> {
|
||||
if key.len() < 32 {
|
||||
return Err("capability too short".into());
|
||||
}
|
||||
let renderer=ReviewedEntryRenderer{session:std::env::var_os("IMPECCABLE_CAPTURE_REVIEW_SESSION").map(PathBuf::from)};
|
||||
let listener = TcpListener::bind("127.0.0.1:0")?;
|
||||
listener.set_nonblocking(true)?;
|
||||
std::fs::write(
|
||||
@@ -141,7 +142,7 @@ pub fn serve(args: &[String]) -> Result<(), Box<dyn std::error::Error>> {
|
||||
"responsive" => EntryStage::Responsive,
|
||||
_ => return Err("invalid stage".into()),
|
||||
};
|
||||
let captured = CdpEntryRenderer.capture_entry(&EntryRequest {
|
||||
let captured = renderer.capture_entry(&EntryRequest {
|
||||
root: PathBuf::from(&root),
|
||||
artifact: text("entry")?.into(),
|
||||
spec: text("spec")?.into(),
|
||||
@@ -155,7 +156,7 @@ pub fn serve(args: &[String]) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let evidence = captured.evidence();
|
||||
let frames:Vec<_>=evidence.frames.iter().map(|f|json!({"name":f.name,"png":base64::engine::general_purpose::STANDARD.encode(&f.png),"regions":f.regions.iter().map(|r|r.receipt.clone()).collect::<Vec<_>>()})).collect();
|
||||
let response =
|
||||
json!({"ok":true,"handle":handle,"report":evidence.report,"frames":frames});
|
||||
json!({"ok":true,"handle":handle,"report":evidence.report,"frames":frames,"approvedReference":captured.approved_reference().map(|a|json!({"png":base64::engine::general_purpose::STANDARD.encode(&a.png),"proof":a.proof}))});
|
||||
if serde_json::to_vec(&response)
|
||||
.map_err(|e| e.to_string())?
|
||||
.len()
|
||||
@@ -246,6 +247,7 @@ impl ServiceEntry {
|
||||
}
|
||||
}
|
||||
impl CapturedEntry for ServiceEntry {
|
||||
fn approved_reference(&self)->Option<&impeccable_comp_verbs::entry_capture::ApprovedReference>{self.source.approved_reference()}
|
||||
fn evidence(&self) -> &impeccable_comp_verbs::entry_capture::EntryEvidence {
|
||||
&self.evidence
|
||||
}
|
||||
@@ -301,6 +303,7 @@ fn audit_saved(
|
||||
if report != evidence.report {
|
||||
return Err("saved capture report differs from host evidence".into());
|
||||
}
|
||||
if let Some(approved)=capture.approved_reference(){if read("human-approved.png")?!=approved.png{return Err("saved human reference differs from reviewed capture".into())}}
|
||||
for frame in &evidence.frames {
|
||||
if read(&format!("{}.png", frame.name))? != frame.png {
|
||||
return Err("saved frame differs from host capture".into());
|
||||
@@ -385,10 +388,12 @@ impl RemoteEntryRenderer {
|
||||
}
|
||||
struct RemoteEntry {
|
||||
renderer: RemoteEntryRenderer,
|
||||
approved: Option<impeccable_comp_verbs::entry_capture::ApprovedReference>,
|
||||
id: String,
|
||||
evidence: impeccable_comp_verbs::entry_capture::EntryEvidence,
|
||||
}
|
||||
impl CapturedEntry for RemoteEntry {
|
||||
fn approved_reference(&self)->Option<&impeccable_comp_verbs::entry_capture::ApprovedReference>{self.approved.as_ref()}
|
||||
fn evidence(&self) -> &impeccable_comp_verbs::entry_capture::EntryEvidence {
|
||||
&self.evidence
|
||||
}
|
||||
@@ -467,6 +472,12 @@ impl EntryRenderer for RemoteEntryRenderer {
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
Ok(Box::new(RemoteEntry {
|
||||
approved: if result["approvedReference"].is_null(){None}else{
|
||||
let a=&result["approvedReference"];
|
||||
let png=base64::engine::general_purpose::STANDARD.decode(a["png"].as_str().ok_or("missing reviewed PNG")?).map_err(|e|e.to_string())?;
|
||||
if a["proof"]["schema"]!="human-assembled-reference-v1" || a["proof"]["sha256"]!=impeccable_comp_verbs::asset_capture::capture_sha256(&png) || a["proof"]!=result["report"]["humanTextReview"] {return Err("invalid human reference proof".into())}
|
||||
Some(impeccable_comp_verbs::entry_capture::ApprovedReference{png,proof:a["proof"].clone()})
|
||||
},
|
||||
renderer: self.clone(),
|
||||
id: id.clone(),
|
||||
evidence: EntryEvidence {
|
||||
|
||||
@@ -6,3 +6,5 @@ pub mod capture_snapshot;
|
||||
pub mod entry_capture;
|
||||
|
||||
pub mod capture_service;
|
||||
|
||||
pub mod reviewed_entry;
|
||||
|
||||
@@ -89,7 +89,7 @@ fn run(args: &[String], io: &mut Io) -> i32 {
|
||||
};
|
||||
match impeccable::capture_service::RemoteEntryRenderer::from_env(&io.env) {
|
||||
Ok(Some(renderer)) => impeccable_comp_verbs::build_phase::run_with_renderer(rest, io, &organic, Some(&renderer)),
|
||||
Ok(None) => impeccable_comp_verbs::build_phase::run_with_renderer(rest, io, &organic, Some(&impeccable::entry_capture::CdpEntryRenderer)),
|
||||
Ok(None) => { let renderer=impeccable::reviewed_entry::ReviewedEntryRenderer::local(&io.cwd,io.home().as_deref()); impeccable_comp_verbs::build_phase::run_with_renderer(rest, io, &organic, Some(&renderer)) },
|
||||
Err(e) => { io.err(&format!("Native capture service: {e}\n")); 1 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
//! A human-approved assembled capture is evidence for text styling, never a
|
||||
//! replacement for native integrity, missing-region, or overall fidelity gates.
|
||||
use crate::entry_capture::CdpEntryRenderer;
|
||||
use impeccable_comp_verbs::asset_capture::capture_sha256;
|
||||
use impeccable_comp_verbs::entry_capture::{
|
||||
ApprovedReference, CapturedEntry, EntryEvidence, EntryRenderer, EntryRequest,
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
use std::{
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
pub struct ReviewedEntryRenderer {
|
||||
pub session: Option<PathBuf>,
|
||||
}
|
||||
struct ReviewedEntry {
|
||||
source: Box<dyn CapturedEntry>,
|
||||
evidence: EntryEvidence,
|
||||
approved: Option<ApprovedReference>,
|
||||
session: Option<PathBuf>,
|
||||
request: EntryRequest,
|
||||
}
|
||||
fn read(path: &Path) -> Result<Value, String> {
|
||||
serde_json::from_slice(&fs::read(path).map_err(|e| e.to_string())?).map_err(|e| e.to_string())
|
||||
}
|
||||
fn file(root: &Path, path: &str) -> Result<Vec<u8>, String> {
|
||||
let mut full = root.to_path_buf();
|
||||
for c in Path::new(path).components() {
|
||||
let std::path::Component::Normal(c) = c else {
|
||||
return Err("invalid reviewed path".into());
|
||||
};
|
||||
full.push(c);
|
||||
if fs::symlink_metadata(&full)
|
||||
.map_err(|e| e.to_string())?
|
||||
.file_type()
|
||||
.is_symlink()
|
||||
{
|
||||
return Err("symlink in reviewed source".into());
|
||||
}
|
||||
}
|
||||
fs::read(full).map_err(|e| e.to_string())
|
||||
}
|
||||
/// Read only private native session state. The host selects the session; neither
|
||||
/// a model-written receipt nor a saved build-phase report is an authority.
|
||||
fn reference(
|
||||
session: &Path,
|
||||
r: &EntryRequest,
|
||||
evidence: &EntryEvidence,
|
||||
) -> Result<ApprovedReference, String> {
|
||||
let root = r.root.canonicalize().map_err(|e| e.to_string())?;
|
||||
if session
|
||||
.canonicalize()
|
||||
.map_err(|e| e.to_string())?
|
||||
.starts_with(&root)
|
||||
{
|
||||
return Err("review session must be outside the project".into());
|
||||
}
|
||||
let state = read(&session.join("current.json"))?;
|
||||
let packet = &state["packet"];
|
||||
let receipt = &state["receipt"];
|
||||
if packet["stage"] != "hero"
|
||||
|| receipt["visualDecision"] != "approved"
|
||||
|| receipt["captureVerified"] != true
|
||||
|| receipt["capture"] != state["capture"]
|
||||
|| state["capture"]["schema"] != "native-component-previews-v1"
|
||||
|| receipt["submission"]["packetRevision"] != packet["revision"]
|
||||
|| receipt["submission"]["requestId"] != packet["id"]
|
||||
{
|
||||
return Err("assembled review is not approved".into());
|
||||
}
|
||||
let components = packet["components"]
|
||||
.as_array()
|
||||
.filter(|c| c.len() == 1)
|
||||
.ok_or("expected one assembled page")?;
|
||||
let component = &components[0];
|
||||
if component["box"] != json!({"x":0,"y":0,"w":1,"h":1})
|
||||
|| component["preview"]["sourceKind"] != "page"
|
||||
{
|
||||
return Err("review did not cover the assembled viewport".into());
|
||||
}
|
||||
let capture = &state["capture"]["components"][0]["views"]["preview"];
|
||||
if capture["kind"] != "assembled-page"
|
||||
|| capture["entry"] != r.artifact
|
||||
|| capture["viewport"]["dpr"] != 1
|
||||
|| capture["viewport"]["width"] != packet["comp"]["width"]
|
||||
|| capture["viewport"]["height"] != packet["comp"]["height"]
|
||||
{
|
||||
return Err("reviewed viewport or entry differs".into());
|
||||
}
|
||||
let sources = state["sources"]
|
||||
.as_object()
|
||||
.ok_or("missing review sources")?;
|
||||
for (path, hash) in sources {
|
||||
if hash.as_str() != Some(&capture_sha256(&file(&root, path)?)) {
|
||||
return Err(format!("reviewed source changed: {path}"));
|
||||
}
|
||||
}
|
||||
if !sources.contains_key(&r.artifact) || !sources.contains_key(&r.reference) {
|
||||
return Err("review must bind entry and original comp".into());
|
||||
}
|
||||
let comp_url = packet["comp"]["url"]
|
||||
.as_str()
|
||||
.ok_or("missing reviewed comp")?;
|
||||
if comp_url
|
||||
!= format!(
|
||||
"/files/{}/{}",
|
||||
packet["revision"].as_str().ok_or("missing revision")?,
|
||||
r.reference
|
||||
)
|
||||
{
|
||||
return Err("reviewed reference differs".into());
|
||||
}
|
||||
// A newly added served file must not borrow an earlier approval.
|
||||
for input in evidence.report["manifest"]["files"]
|
||||
.as_array()
|
||||
.ok_or("missing current native inputs")?
|
||||
{
|
||||
let path = input["path"].as_str().ok_or("invalid input")?;
|
||||
let raster = matches!(
|
||||
Path::new(path).extension().and_then(|s| s.to_str()),
|
||||
Some("png" | "jpg" | "jpeg" | "webp" | "avif" | "gif")
|
||||
);
|
||||
if input["served"] == true
|
||||
&& !raster
|
||||
&& sources.get(input["path"].as_str().ok_or("invalid input")?) != Some(&input["sha256"])
|
||||
{
|
||||
return Err("native inputs differ from reviewed inputs".into());
|
||||
}
|
||||
}
|
||||
// Available but unused raster variants are not dependencies. Every raster
|
||||
// actually observed at the captured breakpoint must belong to the approval.
|
||||
for frame in &evidence.frames {
|
||||
for region in &frame.regions {
|
||||
for binding in region.receipt["resourceBindings"]
|
||||
.as_array()
|
||||
.ok_or("missing observed raster bindings")?
|
||||
{
|
||||
if !sources
|
||||
.values()
|
||||
.any(|h| h == &binding["responseSha256"] && h.is_string())
|
||||
{
|
||||
return Err("unreviewed raster contributes to current page".into());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let hash = capture["screenshotSha256"]
|
||||
.as_str()
|
||||
.filter(|h| h.len() == 64 && h.bytes().all(|c| c.is_ascii_hexdigit()))
|
||||
.ok_or("missing reviewed screenshot digest")?;
|
||||
let preview = component["preview"]["url"]
|
||||
.as_str()
|
||||
.ok_or("missing preview")?;
|
||||
let relative = preview
|
||||
.strip_prefix(&format!("/files/{}/", packet["revision"].as_str().unwrap()))
|
||||
.ok_or("preview revision differs")?;
|
||||
if state["files"][relative] != hash {
|
||||
return Err("reviewed preview digest differs".into());
|
||||
}
|
||||
let png = fs::read(session.join("blobs").join(hash)).map_err(|e| e.to_string())?;
|
||||
if capture_sha256(&png) != hash {
|
||||
return Err("reviewed screenshot changed".into());
|
||||
}
|
||||
Ok(ApprovedReference {
|
||||
png,
|
||||
proof: json!({"schema":"human-assembled-reference-v1","requestId":packet["id"],"packetRevision":packet["revision"],"sha256":hash,"scope":"Text styling accepted in a source-bound assembled-page review; all other gates retained"}),
|
||||
})
|
||||
}
|
||||
impl EntryRenderer for ReviewedEntryRenderer {
|
||||
fn capture_entry(&self, r: &EntryRequest) -> Result<Box<dyn CapturedEntry>, String> {
|
||||
let source = CdpEntryRenderer.capture_entry(r)?;
|
||||
let candidate = self
|
||||
.session
|
||||
.as_ref()
|
||||
.map(|s| reference(s, r, source.evidence()));
|
||||
let mut report = source.evidence().report.clone();
|
||||
if let Some(Err(reason)) = &candidate {
|
||||
report["humanTextReview"] = json!({"status":"not-current","reason":reason});
|
||||
}
|
||||
let approved = candidate.and_then(Result::ok);
|
||||
if let Some(a) = &approved {
|
||||
report["humanTextReview"] = a.proof.clone();
|
||||
}
|
||||
// Frames are produced once by the native adapter; copy bytes for the transport.
|
||||
let frames = source
|
||||
.evidence()
|
||||
.frames
|
||||
.iter()
|
||||
.map(|f| impeccable_comp_verbs::entry_capture::FrameEvidence {
|
||||
name: f.name.clone(),
|
||||
png: f.png.clone(),
|
||||
regions: f
|
||||
.regions
|
||||
.iter()
|
||||
.map(|r| impeccable_comp_verbs::asset_capture::AssetCapture {
|
||||
receipt: r.receipt.clone(),
|
||||
images: vec![],
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
.collect();
|
||||
Ok(Box::new(ReviewedEntry {
|
||||
source,
|
||||
evidence: EntryEvidence { report, frames },
|
||||
approved,
|
||||
session: self.session.clone(),
|
||||
request: EntryRequest {
|
||||
root: r.root.clone(),
|
||||
artifact: r.artifact.clone(),
|
||||
spec: r.spec.clone(),
|
||||
reference: r.reference.clone(),
|
||||
stage: r.stage,
|
||||
},
|
||||
}))
|
||||
}
|
||||
}
|
||||
impl CapturedEntry for ReviewedEntry {
|
||||
fn evidence(&self) -> &EntryEvidence {
|
||||
&self.evidence
|
||||
}
|
||||
fn approved_reference(&self) -> Option<&ApprovedReference> {
|
||||
self.approved.as_ref()
|
||||
}
|
||||
fn verify_current(&self) -> Result<(), String> {
|
||||
self.source.verify_current()?;
|
||||
if let Some(a) = &self.approved {
|
||||
let current = reference(
|
||||
self.session.as_ref().unwrap(),
|
||||
&self.request,
|
||||
self.source.evidence(),
|
||||
)?;
|
||||
if current.png != a.png || current.proof != a.proof {
|
||||
return Err("human review changed during capture".into());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
impl ReviewedEntryRenderer {
|
||||
pub fn local(root: &Path, home: Option<&Path>) -> Self {
|
||||
let session = (|| {
|
||||
let manifest = read(&root.join(".impeccable/review/hero.json")).ok()?;
|
||||
let project = root.canonicalize().ok()?;
|
||||
let id = manifest["id"].as_str()?;
|
||||
// Same project/id key as the shared component-review store.
|
||||
let key = capture_sha256(format!("{}\0{}", project.display(), id).as_bytes());
|
||||
Some(home?.join(".impeccable/component-reviews").join(key))
|
||||
})();
|
||||
Self { session }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn approval_is_bound_to_reviewed_sources_and_the_native_capture() {
|
||||
let dir = std::env::temp_dir().join(format!("review-reference-{}", std::process::id()));
|
||||
let root = dir.join("project");
|
||||
let session = dir.join("session");
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
fs::create_dir_all(&root).unwrap();
|
||||
fs::create_dir_all(session.join("blobs")).unwrap();
|
||||
fs::write(root.join("index.html"), b"page").unwrap();
|
||||
fs::write(root.join("comp.png"), b"comp").unwrap();
|
||||
let page = capture_sha256(b"page");
|
||||
let comp = capture_sha256(b"comp");
|
||||
let png = capture_sha256(b"png");
|
||||
fs::write(session.join("blobs").join(&png), b"png").unwrap();
|
||||
let capture = json!({"schema":"native-component-previews-v1","components":[{"views":{"preview":{"kind":"assembled-page","entry":"index.html","viewport":{"width":1536,"height":1024,"dpr":1},"screenshotSha256":png}}}]});
|
||||
let state = json!({"sources":{"index.html":page,"comp.png":comp},"files":{"preview.png":png},"capture":capture,"packet":{"stage":"hero","id":"hero","revision":"rev","comp":{"width":1536,"height":1024,"url":"/files/rev/comp.png"},"components":[{"box":{"x":0,"y":0,"w":1,"h":1},"preview":{"sourceKind":"page","url":"/files/rev/preview.png"}}]},"receipt":{"visualDecision":"approved","captureVerified":true,"capture":capture,"submission":{"requestId":"hero","packetRevision":"rev"}}});
|
||||
let save = |s: &Value| {
|
||||
fs::write(session.join("current.json"), serde_json::to_vec(s).unwrap()).unwrap()
|
||||
};
|
||||
save(&state);
|
||||
let request = EntryRequest {
|
||||
root: root.clone(),
|
||||
artifact: "index.html".into(),
|
||||
spec: "spec.json".into(),
|
||||
reference: "comp.png".into(),
|
||||
stage: impeccable_comp_verbs::entry_capture::EntryStage::Responsive,
|
||||
};
|
||||
let evidence = EntryEvidence {
|
||||
report: json!({"manifest":{"files":[{"path":"index.html","sha256":page,"served":true}]}}),
|
||||
frames: vec![],
|
||||
};
|
||||
assert_eq!(
|
||||
reference(&session, &request, &evidence).unwrap().png,
|
||||
b"png"
|
||||
);
|
||||
for (pointer, value) in [
|
||||
("/receipt/visualDecision", json!("changes-requested")),
|
||||
("/receipt/submission/packetRevision", json!("old")),
|
||||
("/packet/stage", json!("components")),
|
||||
("/packet/components/0/box/w", json!(0.5)),
|
||||
("/packet/comp/url", json!("/files/rev/other.png")),
|
||||
(
|
||||
"/capture/components/0/views/preview/viewport/width",
|
||||
json!(1440),
|
||||
),
|
||||
] {
|
||||
let mut broken = state.clone();
|
||||
*broken.pointer_mut(pointer).unwrap() = value;
|
||||
save(&broken);
|
||||
assert!(
|
||||
reference(&session, &request, &evidence).is_err(),
|
||||
"{pointer}"
|
||||
);
|
||||
}
|
||||
save(&state);
|
||||
fs::write(root.join("index.html"), b"changed").unwrap();
|
||||
assert!(reference(&session, &request, &evidence).is_err());
|
||||
fs::write(root.join("index.html"), b"page").unwrap();
|
||||
let mut added = EntryEvidence {
|
||||
report: evidence.report.clone(),
|
||||
frames: vec![],
|
||||
};
|
||||
added.report["manifest"]["files"]
|
||||
.as_array_mut()
|
||||
.unwrap()
|
||||
.push(json!({"path":"late.css","sha256":"new","served":true}));
|
||||
assert!(reference(&session, &request, &added).is_err());
|
||||
fs::write(session.join("blobs").join(&png), b"replaced").unwrap();
|
||||
assert!(reference(&session, &request, &evidence).is_err());
|
||||
let _ = fs::remove_dir_all(dir);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user