mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-22 02:56:52 +03:00
Add human component review and prepare Impeccable 4.4.0
Carry native comp capture and completion integrity fixes into the reviewed component workflow. Bump the skill to 4.4.0 and engine/platform pins to 0.1.6; keep publication separate from this release candidate. AI assistance: implemented and validated with OpenAI Codex.
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
(key) => {
|
||||
if (Object.hasOwn(globalThis,key)) throw Error('capture key collision');
|
||||
const nodes=[...document.querySelectorAll('*')];
|
||||
if(nodes.length>5000) throw Error('capture DOM exceeds 5000 elements');
|
||||
const box=el=>{const b=el.getBoundingClientRect();return {x:b.x,y:b.y,w:b.width,h:b.height};};
|
||||
// Computed CSS only. Split complete top-level layers, preserving quoted URL commas.
|
||||
const layers=value=>{
|
||||
const out=[];let start=0,depth=0,quote=null,escaped=false;
|
||||
for(let i=0;i<value.length;i++){
|
||||
const c=value[i];
|
||||
if(escaped){escaped=false;continue;}
|
||||
if(c==='\\'){escaped=true;continue;}
|
||||
if(quote){if(c===quote)quote=null;continue;}
|
||||
if(c==='"'||c==="'"){quote=c;continue;}
|
||||
if(c==='(')depth++;else if(c===')'){if(--depth<0)return null;}
|
||||
else if(c===','&&depth===0){out.push(value.slice(start,i).trim());start=i+1;}
|
||||
}
|
||||
if(depth||quote||escaped)return null;out.push(value.slice(start).trim());
|
||||
if(out.length>16)return null;
|
||||
return out.every(v=>v==='none'||/^url\("([^"\\]*)"\)$/.test(v)||(/^(repeating-)?(linear|radial|conic)-gradient\(/.test(v)&&!/(url|image-set|paint)\(/.test(v)))?out:null;
|
||||
};
|
||||
const state={active:null,pseudos:[]};
|
||||
const computed=s=>({display:s.display,visibility:s.visibility,opacity:s.opacity,objectFit:s.objectFit,objectPosition:s.objectPosition,backgroundSize:s.backgroundSize,backgroundPosition:s.backgroundPosition});
|
||||
state.scan=()=>{
|
||||
const current=[...document.querySelectorAll('*')];
|
||||
const sameNodes=current.length===nodes.length&¤t.every((el,i)=>el===nodes[i]);
|
||||
const rows=[],unsupported=[];
|
||||
for(const [index,el] of current.entries()){
|
||||
const s=getComputedStyle(el),b=box(el);
|
||||
if(el.shadowRoot||['IFRAME','FRAME','CANVAS','VIDEO','SVG'].includes(el.tagName.toUpperCase()))unsupported.push({kind:el.shadowRoot?'shadow-root':el.tagName.toLowerCase(),box:b});
|
||||
const ancestors=[];
|
||||
for(let p=el.parentElement;p;p=p.parentElement){const c=getComputedStyle(p);if(c.opacity!=='1'||c.visibility!=='visible'||c.display==='none'||c.overflow!=='visible'||c.clipPath!=='none')ancestors.push({tag:p.tagName,opacity:c.opacity,visibility:c.visibility,display:c.display,overflow:c.overflow,clipPath:c.clipPath});}
|
||||
if(el instanceof HTMLImageElement&&el.currentSrc)rows.push({kind:'img',url:el.currentSrc,decoded:el.complete&&el.naturalWidth>0,supported:true,index,tag:el.tagName,elementId:el.id,box:b,computed:computed(s),ancestors});
|
||||
for(const pseudo of ['', '::before','::after']){
|
||||
const p=pseudo?getComputedStyle(el,pseudo):s;
|
||||
const bg=p.backgroundImage;
|
||||
const native=pseudo?state.pseudos.find(r=>r.index===index&&r.pseudo===pseudo):null;
|
||||
const bounds=pseudo?native?.box:b;
|
||||
const parts=layers(bg);
|
||||
const urls=parts?.map((part,layer)=>({match:/^url\("([^"\\]*)"\)$/.exec(part),layer})).filter(r=>r.match)||[];
|
||||
const unknown=bg!=='none'&&(!parts||(pseudo&&!urls.length)||(pseudo&&!bounds));
|
||||
if(unknown||(pseudo&&p.content.includes('url('))){unsupported.push({kind:pseudo?'pseudo-imagery':'complex-background',box:bounds||b});continue;}
|
||||
for(const r of urls)rows.push({kind:pseudo?'pseudo-background':'background',pseudo,layer:r.layer,url:new URL(r.match[1],location.href).href,decoded:null,supported:true,index,tag:el.tagName,elementId:el.id,box:bounds,computed:computed(p),ancestors:pseudo?[{tag:el.tagName,...computed(s)},...ancestors]:ancestors});
|
||||
}
|
||||
}
|
||||
return {sameNodes,dom:document.documentElement.outerHTML,url:location.href,
|
||||
viewport:{width:innerWidth,height:innerHeight,dpr:devicePixelRatio,scrollX,scrollY},
|
||||
layout:current.map(box),pseudoLayout:state.pseudos,rows,unsupported,
|
||||
runningAnimations:document.getAnimations().filter(a=>a.playState==='running').length};
|
||||
};
|
||||
// Our temporary changes can start authored transitions. Let them finish naturally.
|
||||
state.settle=()=>Promise.race([
|
||||
(async()=>{await Promise.all(document.getAnimations().map(a=>a.finished));await new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(r)));return true;})(),
|
||||
new Promise((_,reject)=>setTimeout(()=>reject(Error('capture intervention did not settle within 1s')),1000))
|
||||
]);
|
||||
state.suppressMany=(items)=>{
|
||||
if(state.active)throw Error('capture intervention already active');
|
||||
state.active={styles:new Map(),expected:[]};const groups=new Map(),rules=[];
|
||||
for(const item of items){
|
||||
const {index,kind}=item,el=nodes[index];
|
||||
if(!el||el!==document.querySelectorAll('*')[index])throw Error('capture node changed');
|
||||
if(kind!=='pseudo-background'&&!state.active.styles.has(el))state.active.styles.set(el,el.getAttribute('style'));
|
||||
if(kind==='img'){
|
||||
const nw=el.naturalWidth,nh=el.naturalHeight;
|
||||
const distance=Math.ceil(Math.max(nw,nh,el.clientWidth,el.clientHeight)*Math.max(1,el.clientWidth/nw,el.clientHeight/nh)+innerWidth+innerHeight+1);
|
||||
if(!Number.isFinite(distance)||distance>10000000)throw Error('image displacement exceeds capture coordinate budget');
|
||||
el.style.setProperty('object-position',`${distance}px ${distance}px`,'important');
|
||||
}else{
|
||||
const pseudo=item.pseudo||'',id=index+pseudo;
|
||||
if(!groups.has(id))groups.set(id,{index,pseudo,parts:layers(getComputedStyle(el,pseudo||null).backgroundImage),remove:new Set()});
|
||||
const group=groups.get(id);
|
||||
if(!group.parts||!Number.isInteger(item.layer)||!/^url\(/.test(group.parts[item.layer]||''))throw Error('capture background layer changed');
|
||||
group.remove.add(item.layer);
|
||||
}
|
||||
}
|
||||
for(const group of groups.values()){
|
||||
const image=group.parts.map((v,i)=>group.remove.has(i)?'none':v).join(', ');
|
||||
const el=nodes[group.index];
|
||||
if(group.pseudo){
|
||||
const native=state.pseudos.find(r=>r.index===group.index&&r.pseudo===group.pseudo);
|
||||
if(!native?.box)throw Error('capture pseudo geometry unavailable');
|
||||
rules.push(`${native.selector}${group.pseudo}{background-image:${image}!important}`);
|
||||
}else el.style.setProperty('background-image',image,'important');
|
||||
state.active.expected.push({index:group.index,pseudo:group.pseudo,image});
|
||||
}
|
||||
return {dom:document.documentElement.outerHTML,layout:state.scan().layout,rules:rules.join('\n')};
|
||||
};
|
||||
state.verifySuppression=()=>{
|
||||
for(const e of state.active?.expected||[]){if(getComputedStyle(nodes[e.index],e.pseudo||null).backgroundImage!==e.image)throw Error('capture background suppression was overridden');}
|
||||
return true;
|
||||
};
|
||||
state.restore=()=>{
|
||||
if(state.active){for(const [el,style] of state.active.styles)style===null?el.removeAttribute('style'):el.setAttribute('style',style);state.active=null;}
|
||||
};
|
||||
Object.defineProperty(globalThis,key,{value:state,configurable:true});
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,740 @@
|
||||
//! Diagnostic asset capture over isolated Chromium. No approval policy lives here.
|
||||
use base64::Engine;
|
||||
use impeccable_browser::{
|
||||
cdp::{Browser, IsolatedWorld, Page, Viewport},
|
||||
discovery,
|
||||
response_capture::ResponseEvidence,
|
||||
};
|
||||
use impeccable_comp::{png_io, raster::Image};
|
||||
use impeccable_comp_verbs::asset_capture::{
|
||||
AssetCapture, AssetCaptureRequest, AssetRenderer, CaptureImage, capture_sha256 as hash,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
pub struct CdpAssetRenderer {
|
||||
env: HashMap<String, String>,
|
||||
}
|
||||
impl CdpAssetRenderer {
|
||||
pub fn from_process_env() -> Self {
|
||||
Self {
|
||||
env: std::env::vars().collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl AssetRenderer for CdpAssetRenderer {
|
||||
fn capture(&mut self, request: &AssetCaptureRequest) -> Result<AssetCapture, String> {
|
||||
self.capture_batch(std::slice::from_ref(request))?
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or_else(|| "capture returned no evidence".into())
|
||||
}
|
||||
fn capture_batch(
|
||||
&mut self,
|
||||
requests: &[AssetCaptureRequest],
|
||||
) -> Result<Vec<AssetCapture>, String> {
|
||||
if requests.is_empty() || requests.len() > 32 {
|
||||
return Err("capture batch must contain 1 to 32 regions".into());
|
||||
}
|
||||
let request = &requests[0];
|
||||
for item in requests {
|
||||
item.validate()?;
|
||||
if item.url != request.url
|
||||
|| item.viewport != request.viewport
|
||||
|| item.reduced_motion != request.reduced_motion
|
||||
|| item.reference_bytes != request.reference_bytes
|
||||
{
|
||||
return Err(
|
||||
"capture batch requires one URL, viewport, reference and motion setting".into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
let exe = discovery::find_browser(&self.env)
|
||||
.map_err(|e| format!("browser unavailable: {e:?}"))?;
|
||||
// Image suppression must restore exact pixels. GPU tile rasterization can
|
||||
// round resampled pixels differently after an otherwise identical repaint.
|
||||
// Disable partial raster too: reusing invalidated tiles can change antialiasing
|
||||
// even with software rasterization. Never substitute a pixel tolerance.
|
||||
let mut browser = Browser::launch(&exe, &["--disable-gpu-rasterization".into(), "--disable-partial-raster".into()], false)
|
||||
.map_err(|e| e.message)?;
|
||||
let browser_version = browser.version().map_err(|e| e.message)?;
|
||||
let result = (|| {
|
||||
let mut page = browser.new_page().map_err(|e| e.message)?;
|
||||
page.set_viewport(Viewport {
|
||||
width: request.viewport[0],
|
||||
height: request.viewport[1],
|
||||
})
|
||||
.map_err(|e| e.message)?;
|
||||
page.set_reduced_motion(request.reduced_motion)
|
||||
.map_err(|e| e.message)?;
|
||||
page.begin_response_capture().map_err(|e| e.message)?;
|
||||
page.goto(&request.url, "load", Duration::from_secs(30))
|
||||
.map_err(|e| e.message)?;
|
||||
let world = page.create_isolated_world().map_err(|e| e.message)?;
|
||||
let stylesheet = page.create_capture_stylesheet(&world).map_err(|e| e.message)?;
|
||||
let mut capture = CapturePage {
|
||||
page: &mut page,
|
||||
world,
|
||||
started: Instant::now(),
|
||||
stylesheet,
|
||||
};
|
||||
// A timeout is an error, never readiness. No resource is fetched again.
|
||||
eval(
|
||||
&mut capture,
|
||||
r#"Promise.race([(async()=>{await document.fonts.ready;await Promise.all([...document.images].filter(i=>{const b=i.getBoundingClientRect();return b.width>0&&b.height>0&&b.x<innerWidth&&b.y<innerHeight&&b.right>0&&b.bottom>0;}).map(i=>i.decode().catch(()=>{})));return true;})(),new Promise((_,reject)=>setTimeout(()=>reject(Error('capture resources did not settle')),5000))])"#,
|
||||
)?;
|
||||
let key = format!(
|
||||
"__impeccable_capture_{}",
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_nanos()
|
||||
);
|
||||
let key = serde_json::to_string(&key).unwrap();
|
||||
eval(
|
||||
&mut capture,
|
||||
&format!("({})({key})", include_str!("asset_capture.js")),
|
||||
)?;
|
||||
let result: Result<Vec<AssetCapture>, String> = (|| {
|
||||
let mut attempts = Vec::new();
|
||||
let mut anchor: Option<Value> = None;
|
||||
for attempt in 1..=3 {
|
||||
let mut results = Vec::new();
|
||||
for item in requests {
|
||||
results.push(capture_page(&mut capture, item, &key)?);
|
||||
}
|
||||
// Every region must belong to the same settled document and pixels.
|
||||
let first = &results[0].receipt;
|
||||
let identity = |r: &Value| {
|
||||
json!([r["domSha256"], r["captureDocument"], r["networkRevision"]])
|
||||
};
|
||||
if anchor.is_none() {
|
||||
anchor = Some(identity(first));
|
||||
}
|
||||
let same_identity = results.iter().all(|r| {
|
||||
["domSha256", "captureDocument", "networkRevision"]
|
||||
.iter()
|
||||
.all(|k| !r.receipt[*k].is_null())
|
||||
&& Some(identity(&r.receipt)) == anchor
|
||||
});
|
||||
let stable = same_identity
|
||||
&& results.iter().all(|r| {
|
||||
r.receipt["stableCapture"] == true
|
||||
&& [
|
||||
"domSha256",
|
||||
"screenshotSha256",
|
||||
"captureDocument",
|
||||
"networkRevision",
|
||||
]
|
||||
.iter()
|
||||
.all(|k| r.receipt[*k] == first[*k])
|
||||
});
|
||||
attempts.push(json!({"attempt":attempt,"stable":stable,
|
||||
"regions":results.iter().map(|r|json!({"status":r.receipt["status"],"reason":r.receipt["reason"],
|
||||
"screenshotSha256":r.receipt["screenshotSha256"],"domSha256":r.receipt["domSha256"],"networkRevision":r.receipt["networkRevision"]})).collect::<Vec<_>>() }));
|
||||
// Style interventions can invalidate Chromium's raster cache.
|
||||
// Retry the whole batch on the same frozen page; never mix regions
|
||||
// across attempts or relax exact DOM/pixel/network restoration.
|
||||
let raster_retry = same_identity
|
||||
&& results
|
||||
.iter()
|
||||
.any(|r| r.receipt["retryableRasterInvalidation"] == true)
|
||||
&& results.iter().all(|r| {
|
||||
r.receipt["stableCapture"] == true
|
||||
|| r.receipt["retryableRasterInvalidation"] == true
|
||||
});
|
||||
if !stable && raster_retry && attempt < 3 {
|
||||
continue;
|
||||
}
|
||||
let complete =
|
||||
stable && results.iter().all(|r| r.receipt["status"] == "captured");
|
||||
for r in &mut results {
|
||||
r.receipt["batchStabilityVerified"] = json!(stable);
|
||||
r.receipt["batchAttempts"] = json!(attempts);
|
||||
}
|
||||
if !complete && requests.len() > 1 {
|
||||
results = results
|
||||
.into_iter()
|
||||
.map(|mut r| {
|
||||
r.receipt["individualCaptureStatus"] = r.receipt["status"].clone();
|
||||
r.receipt["individualCaptureReason"] = r.receipt["reason"].clone();
|
||||
unavailable(
|
||||
r,
|
||||
if stable {"batch has incomplete surface coverage; stable known-raster observations retained"} else {"batch did not retain one stable document"},
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
return Ok(results);
|
||||
}
|
||||
unreachable!("bounded capture loop always returns its final attempt")
|
||||
})();
|
||||
// Restore even on a failed screenshot/evaluation, then destroy the isolated page.
|
||||
let _ = capture.set_stylesheet("");
|
||||
let _ = eval(
|
||||
&mut capture,
|
||||
&format!(
|
||||
"(()=>{{globalThis[{key}]?.restore();delete globalThis[{key}];return true;}})()"
|
||||
),
|
||||
);
|
||||
drop(capture);
|
||||
page.close();
|
||||
result
|
||||
})();
|
||||
browser.close();
|
||||
result.map(|captures| {
|
||||
captures
|
||||
.into_iter()
|
||||
.map(|mut capture| {
|
||||
capture.receipt["browser"] = browser_version.clone();
|
||||
capture.receipt["rasterization"] = json!("software");
|
||||
capture.receipt["partialRaster"] = json!(false);
|
||||
capture.receipt["nativeVersion"] = json!(env!("CARGO_PKG_VERSION"));
|
||||
capture.receipt["batchSize"] = json!(requests.len());
|
||||
capture
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
}
|
||||
struct CapturePage<'p, 'b> {
|
||||
page: &'p mut Page<'b>,
|
||||
world: IsolatedWorld,
|
||||
started: Instant,
|
||||
stylesheet: String,
|
||||
}
|
||||
impl<'p, 'b> std::ops::Deref for CapturePage<'p, 'b> {
|
||||
type Target = Page<'b>;
|
||||
fn deref(&self) -> &Self::Target {
|
||||
self.page
|
||||
}
|
||||
}
|
||||
impl<'p, 'b> std::ops::DerefMut for CapturePage<'p, 'b> {
|
||||
fn deref_mut(&mut self) -> &mut Self::Target {
|
||||
self.page
|
||||
}
|
||||
}
|
||||
|
||||
impl CapturePage<'_, '_> {
|
||||
fn set_stylesheet(&mut self, text: &str) -> Result<(), String> {
|
||||
self.check_budget()?;
|
||||
self.page.set_capture_stylesheet(&self.world, &self.stylesheet, text).map_err(|e| e.message)
|
||||
}
|
||||
fn check_budget(&self) -> Result<(), String> {
|
||||
if self.started.elapsed() > Duration::from_secs(60) {
|
||||
Err("capture exceeded 60-second command-boundary budget".into())
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
fn coverage(&mut self) -> Result<Value, String> {
|
||||
self.check_budget()?;
|
||||
self.page
|
||||
.capture_dom_coverage(&self.world)
|
||||
.map_err(|e| e.message)
|
||||
}
|
||||
}
|
||||
fn eval(page: &mut CapturePage<'_, '_>, js: &str) -> Result<Value, String> {
|
||||
page.check_budget()?;
|
||||
page.page
|
||||
.evaluate_value_in_world(&page.world, js)
|
||||
.map_err(|e| e.message)
|
||||
}
|
||||
fn scan(page: &mut CapturePage<'_, '_>, key: &str) -> Result<Value, String> {
|
||||
let retained = eval(page, &format!("(globalThis[{key}].active?.expected||[]).filter(r=>r.pseudo).map(r=>({{index:r.index,pseudo:r.pseudo.slice(2)}}))"))?;
|
||||
let retained = retained.as_array().ok_or("capture pseudo identities unavailable")?;
|
||||
let pseudos = page.page.capture_pseudo_geometry(&page.world, retained).map_err(|e| e.message)?;
|
||||
eval(page, &format!("(()=>{{globalThis[{key}].pseudos={pseudos};return globalThis[{key}].scan();}})()"))
|
||||
}
|
||||
fn screenshot(
|
||||
page: &mut CapturePage<'_, '_>,
|
||||
r: &AssetCaptureRequest,
|
||||
) -> Result<(Vec<u8>, Image), String> {
|
||||
let encoded = page.screenshot_viewport().map_err(|e| e.message)?;
|
||||
let png = base64::engine::general_purpose::STANDARD
|
||||
.decode(encoded)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let image = png_io::decode_png(&png)
|
||||
.map_err(|e| format!("screenshot decode: {e:?}"))?
|
||||
.image;
|
||||
if image.width != r.viewport[0] as usize || image.height != r.viewport[1] as usize {
|
||||
return Err("screenshot dimensions differ from viewport".into());
|
||||
}
|
||||
Ok((png, image))
|
||||
}
|
||||
fn overlaps(b: &Value, r: &AssetCaptureRequest) -> bool {
|
||||
let v = |k: &str| b[k].as_f64().unwrap_or(0.);
|
||||
let e = r.expected_box;
|
||||
v("w") > 0.
|
||||
&& v("h") > 0.
|
||||
&& v("x") < e.x + e.w
|
||||
&& v("y") < e.y + e.h
|
||||
&& v("x") + v("w") > e.x
|
||||
&& v("y") + v("h") > e.y
|
||||
}
|
||||
fn urls(s: &Value) -> Vec<String> {
|
||||
let mut result = Vec::new();
|
||||
for row in s["rows"].as_array().into_iter().flatten() {
|
||||
if let Some(u) = row["url"].as_str() {
|
||||
if !result.iter().any(|v| v == u) {
|
||||
result.push(u.to_owned());
|
||||
}
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
fn evidence(page: &mut CapturePage<'_, '_>, urls: &[String]) -> Result<ResponseEvidence, String> {
|
||||
page.response_evidence(urls).map_err(|e| e.message)
|
||||
}
|
||||
fn stable_network(e: &ResponseEvidence, revision: u64) -> bool {
|
||||
!e.truncated && !e.changed_during_collection && e.revision == revision
|
||||
}
|
||||
fn state_reason(s: &Value, r: &AssetCaptureRequest) -> Option<String> {
|
||||
if s["sameNodes"] != true {
|
||||
return Some("DOM nodes changed".into());
|
||||
}
|
||||
if s["runningAnimations"].as_u64().unwrap_or(1) > 0 {
|
||||
return Some("running page animation".into());
|
||||
}
|
||||
let v = &s["viewport"];
|
||||
if v["width"] != r.viewport[0]
|
||||
|| v["height"] != r.viewport[1]
|
||||
|| v["dpr"] != 1.
|
||||
|| v["scrollX"] != 0.
|
||||
|| v["scrollY"] != 0.
|
||||
{
|
||||
return Some("viewport, scale or scroll changed".into());
|
||||
}
|
||||
None
|
||||
}
|
||||
fn receipt(r: &AssetCaptureRequest) -> Value {
|
||||
json!({
|
||||
"schema":"native-asset-capture-diagnostic-v2","inspectionWorld":"isolated","status":"unavailable","requestedUrl":r.url,"reducedMotion":r.reduced_motion,
|
||||
"viewport":{"width":r.viewport[0],"height":r.viewport[1],"dpr":1},"expectedBox":r.expected_box,
|
||||
"referenceSha256":hash(&r.reference_bytes),"assetSha256":hash(&r.asset_bytes),
|
||||
"adequateVisibility":"not-assessed","instances":[],"scope":"Diagnostic evidence only. Observed identity, geometry and paint contribution do not establish fidelity, adequate visibility or generation provenance."
|
||||
})
|
||||
}
|
||||
fn unavailable(mut out: AssetCapture, reason: impl Into<String>) -> AssetCapture {
|
||||
out.receipt["status"] = json!("unavailable");
|
||||
out.receipt["reason"] = json!(reason.into());
|
||||
out
|
||||
}
|
||||
fn static_raster(bytes: &[u8]) -> bool {
|
||||
if bytes.starts_with(&[0xff, 0xd8, 0xff]) {
|
||||
return true;
|
||||
}
|
||||
if !bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
|
||||
return false;
|
||||
}
|
||||
// APNG has an acTL chunk. Read chunk boundaries, not incidental payload text.
|
||||
let mut p = 8;
|
||||
while p + 12 <= bytes.len() {
|
||||
let len = u32::from_be_bytes(bytes[p..p + 4].try_into().unwrap()) as usize;
|
||||
if &bytes[p + 4..p + 8] == b"acTL" {
|
||||
return false;
|
||||
}
|
||||
let Some(next) = p.checked_add(12).and_then(|x| x.checked_add(len)) else {
|
||||
return false;
|
||||
};
|
||||
if next > bytes.len() {
|
||||
return false;
|
||||
}
|
||||
if &bytes[p + 4..p + 8] == b"IEND" {
|
||||
return true;
|
||||
}
|
||||
p = next;
|
||||
}
|
||||
false
|
||||
}
|
||||
fn changed_pixels(a: &Image, b: &Image, r: &AssetCaptureRequest) -> (u64, u64) {
|
||||
let e = r.expected_box;
|
||||
let mut region = 0;
|
||||
let mut total = 0;
|
||||
for y in 0..a.height {
|
||||
for x in 0..a.width {
|
||||
let p = (y * a.width + x) * 4;
|
||||
if a.data[p..p + 4] != b.data[p..p + 4] {
|
||||
total += 1;
|
||||
// Pixel centers define the fixed reference-owned sampling region.
|
||||
if x as f64 + 0.5 >= e.x
|
||||
&& x as f64 + 0.5 < e.x + e.w
|
||||
&& y as f64 + 0.5 >= e.y
|
||||
&& y as f64 + 0.5 < e.y + e.h
|
||||
{
|
||||
region += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
(region, total)
|
||||
}
|
||||
fn capture_page(
|
||||
page: &mut CapturePage<'_, '_>,
|
||||
r: &AssetCaptureRequest,
|
||||
key: &str,
|
||||
) -> Result<AssetCapture, String> {
|
||||
let mut out = AssetCapture {
|
||||
receipt: receipt(r),
|
||||
images: Vec::new(),
|
||||
};
|
||||
let coverage = page.coverage()?;
|
||||
out.receipt["domCoverage"] = coverage.clone();
|
||||
out.receipt["limits"] =
|
||||
json!({"nativeDomNodes":5000,"matchingInstances":16,"commandBoundaryBudgetSeconds":60});
|
||||
if coverage["closedShadowRoots"].as_u64().unwrap_or(1) > 0 {
|
||||
return Ok(unavailable(
|
||||
out,
|
||||
"authored closed shadow content is unsupported",
|
||||
));
|
||||
}
|
||||
let mut settled = None;
|
||||
let mut settling_checks = Vec::new();
|
||||
for attempt in 0..3 {
|
||||
let s = scan(page, key)?;
|
||||
if let Some(reason) = state_reason(&s, r) {
|
||||
out.receipt["unsupported"] = s["unsupported"].clone();
|
||||
return Ok(unavailable(out, reason));
|
||||
}
|
||||
let mut list = urls(&s);
|
||||
if !list.contains(&r.url) {
|
||||
list.push(r.url.clone());
|
||||
}
|
||||
let e = evidence(page, &list)?;
|
||||
let (png, first) = screenshot(page, r)?;
|
||||
let middle = scan(page, key)?;
|
||||
let (second_png, second) = screenshot(page, r)?;
|
||||
let after = scan(page, key)?;
|
||||
let end = evidence(page, &list)?;
|
||||
let (changed_in_region, changed_in_viewport) = changed_pixels(&first, &second, r);
|
||||
settling_checks.push(json!({"attempt":attempt+1,"domBeforeAfterStable":s==middle && s==after,
|
||||
"pixelsStable":first.data==second.data,"changedPixelsInRegion":changed_in_region,"changedPixelsInViewport":changed_in_viewport,
|
||||
"networkStable":stable_network(&end,e.revision),"networkRevisionBefore":e.revision,"networkRevisionAfter":end.revision,
|
||||
"networkChangedDuringCollection":e.changed_during_collection}));
|
||||
out.receipt["settlingChecks"] = json!(settling_checks);
|
||||
out.receipt["settlingResponses"] = json!(end.responses.iter().map(|e|json!({"url":e.url,"status":e.status,"bytes":e.body.as_ref().map(Vec::len),"unavailable":e.unavailable_reason,"fromDiskCache":e.from_disk_cache})).collect::<Vec<_>>());
|
||||
if attempt == 2
|
||||
&& (s != middle
|
||||
|| s != after
|
||||
|| first.data != second.data
|
||||
|| !stable_network(&end, e.revision)
|
||||
|| e.changed_during_collection)
|
||||
{
|
||||
out.images.push(CaptureImage {
|
||||
name: "unsettled-before.png".into(),
|
||||
png: png.clone(),
|
||||
});
|
||||
out.images.push(CaptureImage {
|
||||
name: "unsettled-after.png".into(),
|
||||
png: second_png,
|
||||
});
|
||||
}
|
||||
if s == middle
|
||||
&& s == after
|
||||
&& first.data == second.data
|
||||
&& stable_network(&end, e.revision)
|
||||
&& !e.changed_during_collection
|
||||
{
|
||||
settled = Some((s, png, first, end, list));
|
||||
break;
|
||||
}
|
||||
}
|
||||
let Some((baseline, png, pixels, network, list)) = settled else {
|
||||
out.receipt["settlingResources"] = eval(
|
||||
page,
|
||||
"performance.getEntriesByType('resource').slice(-128).map(r=>({name:r.name,initiatorType:r.initiatorType,startTime:r.startTime,duration:r.duration}))",
|
||||
)?;
|
||||
return Ok(unavailable(
|
||||
out,
|
||||
"page did not settle within three capture checks",
|
||||
));
|
||||
};
|
||||
let document: Vec<_> = network
|
||||
.responses
|
||||
.iter()
|
||||
.filter(|e| e.url == r.url)
|
||||
.collect();
|
||||
if document.len() == 1 && !document[0].ambiguous_url && document[0].unavailable_reason.is_none()
|
||||
{
|
||||
out.receipt["documentResponseSha256"] = json!(document[0].body.as_deref().map(hash));
|
||||
out.receipt["captureDocument"] = json!({"requestId":document[0].request_id,"frameId":document[0].frame_id,"loaderId":document[0].loader_id});
|
||||
}
|
||||
out.receipt["screenshotSha256"] = json!(hash(&png));
|
||||
out.receipt["screenshot"] = json!("baseline.png");
|
||||
out.receipt["domSha256"] = json!(hash(baseline["dom"].as_str().unwrap_or("").as_bytes()));
|
||||
out.receipt["resolvedUrl"] = baseline["url"].clone();
|
||||
out.receipt["networkRevision"] = json!(network.revision);
|
||||
out.images.push(CaptureImage {
|
||||
name: "baseline.png".into(),
|
||||
png,
|
||||
});
|
||||
let unsupported: Vec<Value> = baseline["unsupported"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter(|item| overlaps(&item["box"], r))
|
||||
.cloned()
|
||||
.collect();
|
||||
out.receipt["unsupported"] = json!(unsupported);
|
||||
out.receipt["surfaceCoverage"] = json!({"status":if unsupported.is_empty(){"complete-for-supported-main-dom-surface-types"}else{"partial"},
|
||||
"unmeasuredSurfaces":unsupported,"scope":"Main-DOM IMG and supported URL background layers, including native measured pseudo-elements. Neighboring unknown surfaces cannot supply required-artwork evidence."});
|
||||
let expected = hash(&r.asset_bytes);
|
||||
let matching = baseline["rows"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter(|row| {
|
||||
overlaps(&row["box"], r)
|
||||
&& network.responses.iter().any(|e| {
|
||||
Some(e.url.as_str()) == row["url"].as_str()
|
||||
&& e.body.as_deref().map(hash).as_deref() == Some(expected.as_str())
|
||||
})
|
||||
})
|
||||
.count();
|
||||
if matching > 16 {
|
||||
return Ok(unavailable(out, "capture exceeds 16 matching instances"));
|
||||
}
|
||||
|
||||
let mut bindings = Vec::new();
|
||||
let mut instances = Vec::new();
|
||||
let mut unresolved = false;
|
||||
let mut group = Vec::new();
|
||||
for row in baseline["rows"].as_array().into_iter().flatten() {
|
||||
let candidates: Vec<_> = network
|
||||
.responses
|
||||
.iter()
|
||||
.filter(|e| e.url == row["url"].as_str().unwrap_or(""))
|
||||
.collect();
|
||||
let response = if candidates.len() == 1
|
||||
&& !candidates[0].ambiguous_url
|
||||
&& candidates[0].unavailable_reason.is_none()
|
||||
{
|
||||
Some(candidates[0])
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let body = response.and_then(|e| e.body.as_deref());
|
||||
let body_hash = body.map(hash);
|
||||
let in_region = overlaps(&row["box"], r);
|
||||
let supported = body.map(static_raster).unwrap_or(false) && row["decoded"] != false;
|
||||
bindings.push(json!({"element":row,"responseSha256":body_hash,"supportedStaticRaster":supported,
|
||||
"responseCandidates":candidates.iter().map(|e|json!({"requestId":e.request_id,"frameId":e.frame_id,"loaderId":e.loader_id,"status":e.status,"mimeType":e.mime_type,"ambiguousUrl":e.ambiguous_url,"unavailableReason":e.unavailable_reason,"fromDiskCache":e.from_disk_cache,"fromServiceWorker":e.from_service_worker})).collect::<Vec<_>>()}));
|
||||
if in_region && !supported {
|
||||
unresolved = true;
|
||||
}
|
||||
if body_hash.as_deref() != Some(expected.as_str()) {
|
||||
continue;
|
||||
}
|
||||
let b = &row["box"];
|
||||
let e = r.expected_box;
|
||||
let mut instance = json!({"element":row,"responseSha256":body_hash,
|
||||
"status":"outside-required-region","boxDelta":{"x":b["x"].as_f64().unwrap_or(0.)-e.x,"y":b["y"].as_f64().unwrap_or(0.)-e.y,"w":b["w"].as_f64().unwrap_or(0.)-e.w,"h":b["h"].as_f64().unwrap_or(0.)-e.h}});
|
||||
if !in_region {
|
||||
instances.push(instance);
|
||||
continue;
|
||||
}
|
||||
if !supported {
|
||||
instance["status"] = json!("unavailable");
|
||||
instances.push(instance);
|
||||
continue;
|
||||
}
|
||||
let item = json!({"index":row["index"],"kind":row["kind"],"pseudo":row["pseudo"],"layer":row["layer"]});
|
||||
group.push(item.clone());
|
||||
let (without, without_pixels) = match intervene(
|
||||
page,
|
||||
r,
|
||||
key,
|
||||
&baseline,
|
||||
&pixels,
|
||||
network.revision,
|
||||
&list,
|
||||
&json!([item]),
|
||||
&mut out.images,
|
||||
) {
|
||||
Ok(v) => v,
|
||||
Err(failure) => {
|
||||
out.receipt["retryableRasterInvalidation"] = json!(failure.raster_only);
|
||||
out.receipt["interventionResources"] = eval(
|
||||
page,
|
||||
"performance.getEntriesByType('resource').slice(-128).map(r=>({name:r.name,initiatorType:r.initiatorType,startTime:r.startTime,duration:r.duration}))",
|
||||
)?;
|
||||
return Ok(unavailable(out, failure.reason));
|
||||
}
|
||||
};
|
||||
let (changed, total) = changed_pixels(&pixels, &without_pixels, r);
|
||||
let name = format!("without-{}.png", instances.len());
|
||||
instance["status"] = json!("measured");
|
||||
instance["changedPixelsInRegion"] = json!(changed);
|
||||
instance["changedPixelsInViewport"] = json!(total);
|
||||
instance["suppressedScreenshot"] = json!(name);
|
||||
instance["suppressedScreenshotSha256"] = json!(hash(&without));
|
||||
instance["restorationVerified"] = json!(true);
|
||||
out.images.push(CaptureImage { name, png: without });
|
||||
instances.push(instance);
|
||||
}
|
||||
// Individual marginal contributions can all be zero for identical stacked
|
||||
// copies. Measure their union as well, excluding out-of-region instances.
|
||||
if group.len() > 1 {
|
||||
let (without, without_pixels) = match intervene(
|
||||
page,
|
||||
r,
|
||||
key,
|
||||
&baseline,
|
||||
&pixels,
|
||||
network.revision,
|
||||
&list,
|
||||
&json!(group),
|
||||
&mut out.images,
|
||||
) {
|
||||
Ok(v) => v,
|
||||
Err(failure) => {
|
||||
out.receipt["retryableRasterInvalidation"] = json!(failure.raster_only);
|
||||
out.receipt["interventionResources"] = eval(
|
||||
page,
|
||||
"performance.getEntriesByType('resource').slice(-128).map(r=>({name:r.name,initiatorType:r.initiatorType,startTime:r.startTime,duration:r.duration}))",
|
||||
)?;
|
||||
return Ok(unavailable(out, failure.reason));
|
||||
}
|
||||
};
|
||||
let (changed, total) = changed_pixels(&pixels, &without_pixels, r);
|
||||
out.receipt["combinedContribution"] = json!({"status":"measured","instanceCount":group.len(),"changedPixelsInRegion":changed,"changedPixelsInViewport":total,"suppressedScreenshot":"without-combined.png","suppressedScreenshotSha256":hash(&without),"restorationVerified":true});
|
||||
out.images.push(CaptureImage {
|
||||
name: "without-combined.png".into(),
|
||||
png: without,
|
||||
});
|
||||
} else if let Some(instance) = instances.iter().find(|i| i["status"] == "measured") {
|
||||
out.receipt["combinedContribution"] = json!({"status":"measured","instanceCount":1,"changedPixelsInRegion":instance["changedPixelsInRegion"],"changedPixelsInViewport":instance["changedPixelsInViewport"],"suppressedScreenshot":instance["suppressedScreenshot"],"suppressedScreenshotSha256":instance["suppressedScreenshotSha256"],"restorationVerified":true});
|
||||
} else {
|
||||
out.receipt["combinedContribution"] =
|
||||
json!({"status":"no-matching-supported-instance","instanceCount":0});
|
||||
}
|
||||
out.receipt["resourceBindings"] = json!(bindings);
|
||||
out.receipt["instances"] = json!(instances);
|
||||
let (_, final_pixels) = screenshot(page, r)?;
|
||||
if scan(page, key)? != baseline
|
||||
|| final_pixels.data != pixels.data
|
||||
|| !stable_network(&evidence(page, &list)?, network.revision)
|
||||
{
|
||||
return Ok(unavailable(out, "page changed by final capture check"));
|
||||
}
|
||||
let final_coverage = page.coverage()?;
|
||||
if final_coverage != coverage {
|
||||
return Ok(unavailable(
|
||||
out,
|
||||
"native DOM coverage changed during capture",
|
||||
));
|
||||
}
|
||||
// Unknown neighboring renderers do not prevent an exact intervention on
|
||||
// an observed PNG. Preserve that scoped evidence while keeping the broader
|
||||
// capture unavailable; it is not a pass or an exhaustive surface census.
|
||||
out.receipt["stableCapture"] = json!(true);
|
||||
out.receipt["knownRasterEvidence"] = json!({"status":"measured","adequateVisibility":"not-assessed","inventoryCoverage":if unresolved || !unsupported.is_empty(){"partial"}else{"supported-main-dom-types"}});
|
||||
if !unsupported.is_empty() {
|
||||
return Ok(unavailable(
|
||||
out,
|
||||
"unsupported rendered surface in required region; known raster contribution measured separately",
|
||||
));
|
||||
}
|
||||
if unresolved {
|
||||
return Ok(unavailable(
|
||||
out,
|
||||
"resource identity, decode or format unavailable in required region",
|
||||
));
|
||||
}
|
||||
out.receipt["status"] = json!("captured");
|
||||
out.receipt["stableCapture"] = json!(true);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
struct InterventionFailure {
|
||||
reason: String,
|
||||
raster_only: bool,
|
||||
}
|
||||
impl From<String> for InterventionFailure {
|
||||
fn from(reason: String) -> Self {
|
||||
Self {
|
||||
reason,
|
||||
raster_only: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl From<&str> for InterventionFailure {
|
||||
fn from(reason: &str) -> Self {
|
||||
reason.to_string().into()
|
||||
}
|
||||
}
|
||||
|
||||
fn intervene(
|
||||
page: &mut CapturePage<'_, '_>,
|
||||
r: &AssetCaptureRequest,
|
||||
key: &str,
|
||||
baseline: &Value,
|
||||
pixels: &Image,
|
||||
revision: u64,
|
||||
list: &[String],
|
||||
items: &Value,
|
||||
diagnostics: &mut Vec<CaptureImage>,
|
||||
) -> Result<(Vec<u8>, Image), InterventionFailure> {
|
||||
let before = scan(page, key)?;
|
||||
let (_, before_pixels) = screenshot(page, r)?;
|
||||
if &before != baseline
|
||||
|| before_pixels.data != pixels.data
|
||||
|| !stable_network(&evidence(page, list)?, revision)
|
||||
{
|
||||
return Err("page changed before intervention".into());
|
||||
}
|
||||
let suppressed = eval(page, &format!("globalThis[{key}].suppressMany({items})"));
|
||||
let intervention: Result<(Vec<u8>, Image), String> = (|| {
|
||||
let suppressed = suppressed?;
|
||||
page.set_stylesheet(suppressed["rules"].as_str().ok_or("capture suppression rules unavailable")?)?;
|
||||
eval(page, &format!("globalThis[{key}].settle()"))?;
|
||||
eval(page, &format!("globalThis[{key}].verifySuppression()"))?;
|
||||
let (without, without_pixels) = screenshot(page, r)?;
|
||||
let during = scan(page, key)?;
|
||||
if during["dom"] != suppressed["dom"]
|
||||
|| during["layout"] != baseline["layout"]
|
||||
|| suppressed["layout"] != baseline["layout"]
|
||||
|| during["pseudoLayout"] != baseline["pseudoLayout"]
|
||||
|| during["sameNodes"] != true
|
||||
|| during["runningAnimations"].as_u64().unwrap_or(1) > 0
|
||||
{
|
||||
return Err("page, layout or animation changed during intervention".into());
|
||||
}
|
||||
Ok((without, without_pixels))
|
||||
})();
|
||||
let stylesheet_restore = page.set_stylesheet("");
|
||||
eval(
|
||||
page,
|
||||
&format!("(()=>{{globalThis[{key}].restore();return globalThis[{key}].settle();}})()"),
|
||||
)?;
|
||||
stylesheet_restore?;
|
||||
let result = intervention?;
|
||||
let restored_dom = scan(page, key)?;
|
||||
let (restored_png, restored_pixels) = screenshot(page, r)?;
|
||||
let restored_network = evidence(page, list)?;
|
||||
if &restored_dom != baseline
|
||||
|| restored_pixels.data != pixels.data
|
||||
|| !stable_network(&restored_network, revision)
|
||||
{
|
||||
diagnostics.push(CaptureImage {
|
||||
name: "restoration-failed.png".into(),
|
||||
png: restored_png,
|
||||
});
|
||||
return Err(InterventionFailure {
|
||||
raster_only: &restored_dom == baseline && stable_network(&restored_network, revision),
|
||||
reason: format!(
|
||||
"intervention did not restore stable page and network (DOM {}, pixels {}, network revision {} -> {}, truncated {}, changed while reading {})",
|
||||
&restored_dom == baseline,
|
||||
restored_pixels.data == pixels.data,
|
||||
revision,
|
||||
restored_network.revision,
|
||||
restored_network.truncated,
|
||||
restored_network.changed_during_collection
|
||||
),
|
||||
});
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
@@ -0,0 +1,554 @@
|
||||
//! 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 base64::Engine;
|
||||
use impeccable_comp_verbs::entry_capture::{
|
||||
CapturedEntry, EntryRenderer, EntryRequest, EntryStage,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
io::{BufRead, BufReader, Read, Write},
|
||||
net::{TcpListener, TcpStream},
|
||||
path::PathBuf,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
fn request(stream: &TcpStream, key: &str) -> Result<(String, Value), String> {
|
||||
let mut reader = BufReader::new(stream.try_clone().map_err(|e| e.to_string())?);
|
||||
let mut first = String::new();
|
||||
reader
|
||||
.by_ref()
|
||||
.take(1025)
|
||||
.read_line(&mut first)
|
||||
.map_err(|e| e.to_string())?;
|
||||
if first.len() > 1024 {
|
||||
return Err("request line too long".into());
|
||||
}
|
||||
let parts: Vec<_> = first.split_whitespace().collect();
|
||||
if parts.len() != 3 || parts[0] != "POST" {
|
||||
return Err("POST required".into());
|
||||
}
|
||||
let route = parts[1].to_string();
|
||||
let mut total = first.len();
|
||||
let mut length = None;
|
||||
let mut authenticated = false;
|
||||
let mut key_seen = false;
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
reader
|
||||
.by_ref()
|
||||
.take(8193)
|
||||
.read_line(&mut line)
|
||||
.map_err(|e| e.to_string())?;
|
||||
total += line.len();
|
||||
if total > 8192 || line.is_empty() {
|
||||
return Err("invalid headers".into());
|
||||
}
|
||||
if line == "\r\n" || line == "\n" {
|
||||
break;
|
||||
}
|
||||
let (name, value) = line.split_once(':').ok_or("invalid header")?;
|
||||
match name.to_ascii_lowercase().as_str() {
|
||||
"content-length" => {
|
||||
if length.is_some() {
|
||||
return Err("duplicate length".into());
|
||||
}
|
||||
length = Some(
|
||||
value
|
||||
.trim()
|
||||
.parse::<usize>()
|
||||
.map_err(|_| "invalid length")?,
|
||||
);
|
||||
}
|
||||
"x-capture-key" => {
|
||||
if key_seen {
|
||||
return Err("duplicate capability".into());
|
||||
}
|
||||
key_seen = true;
|
||||
authenticated = value.trim() == key;
|
||||
}
|
||||
"origin" | "transfer-encoding" => {
|
||||
return Err("unsupported request origin/encoding".into());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if !authenticated {
|
||||
return Err("invalid capability".into());
|
||||
}
|
||||
let length = length.ok_or("missing length")?;
|
||||
if length > 16384 {
|
||||
return Err("request too large".into());
|
||||
}
|
||||
let mut body = vec![0; length];
|
||||
reader.read_exact(&mut body).map_err(|e| e.to_string())?;
|
||||
let body: Value = serde_json::from_slice(&body).map_err(|e| e.to_string())?;
|
||||
if !body.is_object() || body.get("root").is_some() || body.get("url").is_some() {
|
||||
return Err("registered root only; no caller URLs".into());
|
||||
}
|
||||
Ok((route, body))
|
||||
}
|
||||
pub fn serve(args: &[String]) -> Result<(), Box<dyn std::error::Error>> {
|
||||
if args.len() != 2 {
|
||||
return Err("expected registered-root ready-file".into());
|
||||
}
|
||||
let root = std::fs::canonicalize(&args[0])?;
|
||||
let key = std::env::var("IMPECCABLE_CAPTURE_CAPABILITY")?;
|
||||
if key.len() < 32 {
|
||||
return Err("capability too short".into());
|
||||
}
|
||||
let listener = TcpListener::bind("127.0.0.1:0")?;
|
||||
listener.set_nonblocking(true)?;
|
||||
std::fs::write(
|
||||
&args[1],
|
||||
serde_json::to_vec(&json!({"port":listener.local_addr()?.port(),"root":root}))?,
|
||||
)?;
|
||||
let started = Instant::now();
|
||||
let mut serial = 0u64;
|
||||
let mut captures: HashMap<String, (Instant, Box<dyn CapturedEntry>)> = HashMap::new();
|
||||
let mut latest: HashMap<String, String> = HashMap::new();
|
||||
let mut active = std::collections::HashSet::new();
|
||||
let session = &impeccable_comp_verbs::asset_capture::capture_sha256(key.as_bytes())[..16];
|
||||
while started.elapsed() < Duration::from_secs(10800) {
|
||||
active.retain(|id| {
|
||||
captures
|
||||
.get(id)
|
||||
.is_some_and(|(at, _)| at.elapsed() < Duration::from_secs(180))
|
||||
});
|
||||
captures.retain(|id, _| active.contains(id) || latest.values().any(|v| v == id));
|
||||
let (mut stream, _) = match listener.accept() {
|
||||
Ok(v) => v,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
continue;
|
||||
}
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
stream.set_nonblocking(false)?;
|
||||
stream.set_read_timeout(Some(Duration::from_secs(5)))?;
|
||||
stream.set_write_timeout(Some(Duration::from_secs(10)))?;
|
||||
let answer = (|| -> Result<Value, String> {
|
||||
let (route, body) = request(&stream, &key)?;
|
||||
let text = |k: &str| body[k].as_str().ok_or_else(|| format!("missing {k}"));
|
||||
match route.as_str() {
|
||||
"/capture" => {
|
||||
if active.len() >= 2 {
|
||||
return Err("active capture limit".into());
|
||||
}
|
||||
let stage = match text("stage")? {
|
||||
"hero" => EntryStage::Hero,
|
||||
"responsive" => EntryStage::Responsive,
|
||||
_ => return Err("invalid stage".into()),
|
||||
};
|
||||
let captured = CdpEntryRenderer.capture_entry(&EntryRequest {
|
||||
root: PathBuf::from(&root),
|
||||
artifact: text("entry")?.into(),
|
||||
spec: text("spec")?.into(),
|
||||
reference: text("reference")?.into(),
|
||||
stage,
|
||||
})?;
|
||||
serial += 1;
|
||||
let handle = format!("{session}-{serial}");
|
||||
let stage_name = text("stage")?.to_string();
|
||||
let captured = ServiceEntry::new(captured, handle.clone(), &root);
|
||||
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});
|
||||
if serde_json::to_vec(&response)
|
||||
.map_err(|e| e.to_string())?
|
||||
.len()
|
||||
> 64 * 1024 * 1024
|
||||
{
|
||||
return Err("response budget exceeded".into());
|
||||
}
|
||||
latest.insert(stage_name, handle.clone());
|
||||
active.insert(handle.clone());
|
||||
captures.insert(handle, (Instant::now(), Box::new(captured)));
|
||||
Ok(response)
|
||||
}
|
||||
"/verify" => {
|
||||
let handle = text("handle")?;
|
||||
if !active.contains(handle) {
|
||||
return Err("unknown or released capture".into());
|
||||
}
|
||||
let (_, capture) = captures.get(handle).ok_or("unknown capture")?;
|
||||
capture.verify_current()?;
|
||||
Ok(json!({"ok":true}))
|
||||
}
|
||||
"/release" => Ok(json!({"ok":active.remove(text("handle")?)})),
|
||||
"/audit" => {
|
||||
let stage = text("stage")?;
|
||||
if !matches!(stage, "hero" | "responsive") {
|
||||
return Err("invalid stage".into());
|
||||
}
|
||||
let id = latest.get(stage).ok_or("no host capture for stage")?;
|
||||
let (_, capture) = captures.get(id).ok_or("missing host capture")?;
|
||||
let saved_evidence = audit_saved(&root, stage, capture.as_ref())?;
|
||||
Ok(
|
||||
json!({"ok":true,"captureId":id,"inputSnapshot":capture.evidence().report["inputSnapshot"],"manifest":capture.evidence().report["manifest"],"savedEvidence":saved_evidence,"stage":stage}),
|
||||
)
|
||||
}
|
||||
_ => Err("unknown operation".into()),
|
||||
}
|
||||
})();
|
||||
let (status, body) = match answer {
|
||||
Ok(v) => (200, v),
|
||||
Err(e) => (400, json!({"ok":false,"error":e})),
|
||||
};
|
||||
let bytes = serde_json::to_vec(&body)?;
|
||||
let header = format!(
|
||||
"HTTP/1.1 {status} Result\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
||||
bytes.len()
|
||||
);
|
||||
let _ = stream
|
||||
.write_all(header.as_bytes())
|
||||
.and_then(|_| stream.write_all(&bytes));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct ServiceEntry {
|
||||
source: Box<dyn CapturedEntry>,
|
||||
evidence: impeccable_comp_verbs::entry_capture::EntryEvidence,
|
||||
}
|
||||
impl ServiceEntry {
|
||||
fn new(source: Box<dyn CapturedEntry>, id: String, root: &std::path::Path) -> Self {
|
||||
use impeccable_comp_verbs::{
|
||||
asset_capture::AssetCapture,
|
||||
entry_capture::{EntryEvidence, FrameEvidence},
|
||||
};
|
||||
let e = source.evidence();
|
||||
let mut report = e.report.clone();
|
||||
report["captureService"] =
|
||||
json!({"schema":"native-capture-service-v1","id":id,"registeredRoot":root});
|
||||
let frames = e
|
||||
.frames
|
||||
.iter()
|
||||
.map(|f| FrameEvidence {
|
||||
name: f.name.clone(),
|
||||
png: f.png.clone(),
|
||||
regions: f
|
||||
.regions
|
||||
.iter()
|
||||
.map(|r| AssetCapture {
|
||||
receipt: r.receipt.clone(),
|
||||
images: vec![],
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
.collect();
|
||||
Self {
|
||||
source,
|
||||
evidence: EntryEvidence { report, frames },
|
||||
}
|
||||
}
|
||||
}
|
||||
impl CapturedEntry for ServiceEntry {
|
||||
fn evidence(&self) -> &impeccable_comp_verbs::entry_capture::EntryEvidence {
|
||||
&self.evidence
|
||||
}
|
||||
fn verify_current(&self) -> Result<(), String> {
|
||||
self.source.verify_current()
|
||||
}
|
||||
}
|
||||
|
||||
fn saved(root: &std::path::Path, relative: &str) -> Result<Vec<u8>, String> {
|
||||
let mut path = root.to_path_buf();
|
||||
for c in std::path::Path::new(relative).components() {
|
||||
let std::path::Component::Normal(c) = c else {
|
||||
return Err("invalid saved evidence path".into());
|
||||
};
|
||||
path.push(c);
|
||||
if std::fs::symlink_metadata(&path)
|
||||
.map_err(|e| e.to_string())?
|
||||
.file_type()
|
||||
.is_symlink()
|
||||
{
|
||||
return Err("symlink in saved evidence".into());
|
||||
}
|
||||
}
|
||||
let file = std::fs::File::open(path).map_err(|e| e.to_string())?;
|
||||
if !file.metadata().map_err(|e| e.to_string())?.is_file() {
|
||||
return Err("saved evidence is not a file".into());
|
||||
}
|
||||
let mut bytes = Vec::new();
|
||||
file.take(64 * 1024 * 1024 + 1)
|
||||
.read_to_end(&mut bytes)
|
||||
.map_err(|e| e.to_string())?;
|
||||
if bytes.len() > 64 * 1024 * 1024 {
|
||||
return Err("saved evidence exceeds budget".into());
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
fn audit_saved(
|
||||
root: &std::path::Path,
|
||||
stage: &str,
|
||||
capture: &dyn CapturedEntry,
|
||||
) -> Result<Vec<Value>, String> {
|
||||
capture.verify_current()?;
|
||||
let base = format!(".impeccable/review/native/{stage}");
|
||||
let evidence = capture.evidence();
|
||||
let mut files = Vec::new();
|
||||
let mut read = |name: &str| -> Result<Vec<u8>, String> {
|
||||
let path = format!("{base}/{name}");
|
||||
let bytes = saved(root, &path)?;
|
||||
files.push(json!({"path":path,"bytes":bytes.len(),"sha256":impeccable_comp_verbs::asset_capture::capture_sha256(&bytes)}));
|
||||
Ok(bytes)
|
||||
};
|
||||
let report: Value = serde_json::from_slice(&read("inputs.json")?).map_err(|e| e.to_string())?;
|
||||
if report != evidence.report {
|
||||
return Err("saved capture report differs from host evidence".into());
|
||||
}
|
||||
for frame in &evidence.frames {
|
||||
if read(&format!("{}.png", frame.name))? != frame.png {
|
||||
return Err("saved frame differs from host capture".into());
|
||||
}
|
||||
let observations: Value =
|
||||
serde_json::from_slice(&read(&format!("{}-observations.json", frame.name))?)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let expected: Vec<_> = frame.regions.iter().map(|r| r.receipt.clone()).collect();
|
||||
if observations != json!(expected) {
|
||||
return Err("saved observations differ from host capture".into());
|
||||
}
|
||||
}
|
||||
capture.verify_current()?;
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct RemoteEntryRenderer {
|
||||
port: u16,
|
||||
key: String,
|
||||
}
|
||||
impl RemoteEntryRenderer {
|
||||
pub fn from_env(env: &HashMap<String, String>) -> Result<Option<Self>, String> {
|
||||
match (
|
||||
env.get("IMPECCABLE_CAPTURE_PORT"),
|
||||
env.get("IMPECCABLE_CAPTURE_CAPABILITY"),
|
||||
) {
|
||||
(None, None) => Ok(None),
|
||||
(Some(port), Some(key))
|
||||
if key.len() == 64 && key.bytes().all(|b| b.is_ascii_hexdigit()) =>
|
||||
{
|
||||
let port: u16 = port.parse().map_err(|_| "invalid native capture port")?;
|
||||
if port == 0 {
|
||||
return Err("invalid native capture port".into());
|
||||
}
|
||||
Ok(Some(Self {
|
||||
port,
|
||||
key: key.clone(),
|
||||
}))
|
||||
}
|
||||
_ => Err("incomplete native capture service configuration".into()),
|
||||
}
|
||||
}
|
||||
fn call(&self, route: &str, body: Value) -> Result<Value, String> {
|
||||
let body = serde_json::to_vec(&body).map_err(|e| e.to_string())?;
|
||||
let mut stream = TcpStream::connect_timeout(
|
||||
&std::net::SocketAddr::from(([127, 0, 0, 1], self.port)),
|
||||
Duration::from_secs(3),
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
stream
|
||||
.set_read_timeout(Some(Duration::from_secs(150)))
|
||||
.map_err(|e| e.to_string())?;
|
||||
stream
|
||||
.set_write_timeout(Some(Duration::from_secs(5)))
|
||||
.map_err(|e| e.to_string())?;
|
||||
write!(stream,"POST {route} HTTP/1.1\r\nHost: 127.0.0.1\r\nX-Capture-Key: {}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",self.key,body.len()).map_err(|e|e.to_string())?;
|
||||
stream.write_all(&body).map_err(|e| e.to_string())?;
|
||||
let mut bytes = Vec::new();
|
||||
stream
|
||||
.take(64 * 1024 * 1024 + 8193)
|
||||
.read_to_end(&mut bytes)
|
||||
.map_err(|e| e.to_string())?;
|
||||
if bytes.len() > 64 * 1024 * 1024 + 8192 {
|
||||
return Err("native capture response too large".into());
|
||||
}
|
||||
let split = bytes
|
||||
.windows(4)
|
||||
.position(|v| v == b"\r\n\r\n")
|
||||
.filter(|n| *n < 8192)
|
||||
.ok_or("invalid native capture HTTP response")?;
|
||||
let response: Value =
|
||||
serde_json::from_slice(&bytes[split + 4..]).map_err(|e| e.to_string())?;
|
||||
if response["ok"] != true {
|
||||
return Err(response["error"]
|
||||
.as_str()
|
||||
.unwrap_or("native service rejected capture")
|
||||
.into());
|
||||
}
|
||||
Ok(response)
|
||||
}
|
||||
}
|
||||
struct RemoteEntry {
|
||||
renderer: RemoteEntryRenderer,
|
||||
id: String,
|
||||
evidence: impeccable_comp_verbs::entry_capture::EntryEvidence,
|
||||
}
|
||||
impl CapturedEntry for RemoteEntry {
|
||||
fn evidence(&self) -> &impeccable_comp_verbs::entry_capture::EntryEvidence {
|
||||
&self.evidence
|
||||
}
|
||||
fn verify_current(&self) -> Result<(), String> {
|
||||
self.renderer
|
||||
.call("/verify", json!({"handle":self.id}))
|
||||
.map(|_| ())
|
||||
}
|
||||
}
|
||||
impl Drop for RemoteEntry {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.renderer.call("/release", json!({"handle":self.id}));
|
||||
}
|
||||
}
|
||||
impl EntryRenderer for RemoteEntryRenderer {
|
||||
fn capture_entry(&self, r: &EntryRequest) -> Result<Box<dyn CapturedEntry>, String> {
|
||||
use impeccable_comp_verbs::{
|
||||
asset_capture::AssetCapture,
|
||||
entry_capture::{EntryEvidence, FrameEvidence},
|
||||
};
|
||||
let stage = match r.stage {
|
||||
EntryStage::Hero => "hero",
|
||||
EntryStage::Responsive => "responsive",
|
||||
};
|
||||
let result = self.call(
|
||||
"/capture",
|
||||
json!({"entry":r.artifact,"spec":r.spec,"reference":r.reference,"stage":stage}),
|
||||
)?;
|
||||
let id = result["handle"]
|
||||
.as_str()
|
||||
.ok_or("missing native capture handle")?
|
||||
.to_string();
|
||||
let parsed = (|| -> Result<Box<dyn CapturedEntry>, String> {
|
||||
let root = std::fs::canonicalize(&r.root).map_err(|e| e.to_string())?;
|
||||
if result["report"]["captureService"]["registeredRoot"] != json!(root)
|
||||
|| result["report"]["captureService"]["id"] != id
|
||||
|| result["report"]["stage"] != stage
|
||||
|| result["report"]["artifact"] != r.artifact
|
||||
{
|
||||
return Err("native capture binding mismatch".into());
|
||||
}
|
||||
let expected = if stage == "hero" {
|
||||
vec!["hero"]
|
||||
} else {
|
||||
vec!["desktop", "mobile"]
|
||||
};
|
||||
let frames = result["frames"]
|
||||
.as_array()
|
||||
.filter(|f| f.len() == expected.len())
|
||||
.ok_or("invalid native capture frames")?;
|
||||
let frames = frames
|
||||
.iter()
|
||||
.zip(expected)
|
||||
.map(|(f, name)| -> Result<FrameEvidence, String> {
|
||||
if f["name"] != name {
|
||||
return Err("invalid native capture frame name".into());
|
||||
}
|
||||
let png = base64::engine::general_purpose::STANDARD
|
||||
.decode(f["png"].as_str().ok_or("missing frame PNG")?)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let regions = f["regions"]
|
||||
.as_array()
|
||||
.filter(|r| !r.is_empty() && r.len() <= 32)
|
||||
.ok_or("invalid native capture regions")?
|
||||
.iter()
|
||||
.map(|r| AssetCapture {
|
||||
receipt: r.clone(),
|
||||
images: vec![],
|
||||
})
|
||||
.collect();
|
||||
Ok(FrameEvidence {
|
||||
name: name.into(),
|
||||
png,
|
||||
regions,
|
||||
})
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
Ok(Box::new(RemoteEntry {
|
||||
renderer: self.clone(),
|
||||
id: id.clone(),
|
||||
evidence: EntryEvidence {
|
||||
report: result["report"].clone(),
|
||||
frames,
|
||||
},
|
||||
}))
|
||||
})();
|
||||
if parsed.is_err() {
|
||||
let _ = self.call("/release", json!({"handle":id}));
|
||||
}
|
||||
parsed
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
fn parse(bytes: String) -> Result<(String, Value), String> {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let writer = std::thread::spawn(move || {
|
||||
let mut s = TcpStream::connect(addr).unwrap();
|
||||
let _ = s.write_all(bytes.as_bytes());
|
||||
});
|
||||
let (stream, _) = listener.accept().unwrap();
|
||||
stream
|
||||
.set_read_timeout(Some(Duration::from_secs(1)))
|
||||
.unwrap();
|
||||
let result = request(&stream, &"a".repeat(64));
|
||||
writer.join().unwrap();
|
||||
result
|
||||
}
|
||||
#[test]
|
||||
fn request_parser_rejects_origin_ambiguity_and_unbounded_inputs() {
|
||||
let header = format!(
|
||||
"POST /capture HTTP/1.1\r\nX-Capture-Key: {}\r\n",
|
||||
"a".repeat(64)
|
||||
);
|
||||
assert!(parse(format!("{header}Content-Length: 2\r\n\r\n{{}}")).is_ok());
|
||||
for extra in [
|
||||
"Origin: http://example.com\r\n",
|
||||
"Transfer-Encoding: chunked\r\n",
|
||||
"Content-Length: 2\r\n",
|
||||
"X-Capture-Key: wrong\r\n",
|
||||
] {
|
||||
assert!(parse(format!("{header}{extra}Content-Length: 2\r\n\r\n{{}}")).is_err());
|
||||
}
|
||||
assert!(parse("POST /capture HTTP/1.1\r\nContent-Length: 2\r\n\r\n{}".into()).is_err());
|
||||
assert!(parse(format!("{header}Content-Length: 16385\r\n\r\n")).is_err());
|
||||
assert!(
|
||||
parse(format!(
|
||||
"{header}X-Large: {}\r\nContent-Length: 2\r\n\r\n{{}}",
|
||||
"x".repeat(8192)
|
||||
))
|
||||
.is_err()
|
||||
);
|
||||
for body in [r#"{"root":"/"}"#, r#"{"url":"file:///outside"}"#, "[]"] {
|
||||
assert!(
|
||||
parse(format!(
|
||||
"{header}Content-Length: {}\r\n\r\n{body}",
|
||||
body.len()
|
||||
))
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn partial_service_configuration_never_falls_back_to_local_capture() {
|
||||
assert!(
|
||||
RemoteEntryRenderer::from_env(&HashMap::new())
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
let mut env = HashMap::from([("IMPECCABLE_CAPTURE_PORT".into(), "12345".into())]);
|
||||
assert!(RemoteEntryRenderer::from_env(&env).is_err());
|
||||
env.insert("IMPECCABLE_CAPTURE_CAPABILITY".into(), "a".repeat(64));
|
||||
assert!(RemoteEntryRenderer::from_env(&env).unwrap().is_some());
|
||||
env.insert(
|
||||
"IMPECCABLE_CAPTURE_PORT".into(),
|
||||
"http://example.com".into(),
|
||||
);
|
||||
assert!(RemoteEntryRenderer::from_env(&env).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,472 @@
|
||||
//! Immutable, explicit static-HTML inputs for native capture. Not a framework server.
|
||||
//! The native caller owns the selection; private bound inputs are never HTTP routes.
|
||||
use impeccable_comp_verbs::asset_capture::capture_sha256 as hash;
|
||||
use serde_json::{Value, json};
|
||||
use std::{
|
||||
collections::{BTreeMap, BTreeSet},
|
||||
fs::{self, File},
|
||||
io::{Read, Write},
|
||||
net::{TcpListener, TcpStream},
|
||||
path::{Component, Path, PathBuf},
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
},
|
||||
thread::{self, JoinHandle},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
const MAX_FILES: usize = 1024;
|
||||
const MAX_FILE_BYTES: u64 = 64 * 1024 * 1024;
|
||||
const MAX_TOTAL_BYTES: usize = 128 * 1024 * 1024;
|
||||
|
||||
pub struct SnapshotSelection {
|
||||
pub root: PathBuf,
|
||||
pub entry: String,
|
||||
/// Explicit local static dependencies, including the entry. No directory crawling.
|
||||
pub served: Vec<String>,
|
||||
/// Spec, reference and other gate inputs. Hashed but never served unless also in served.
|
||||
pub bound: Vec<String>,
|
||||
}
|
||||
pub struct HtmlSnapshot {
|
||||
root: PathBuf,
|
||||
entry: String,
|
||||
served: BTreeSet<String>,
|
||||
files: BTreeMap<String, Vec<u8>>,
|
||||
manifest: Value,
|
||||
digest: String,
|
||||
}
|
||||
impl HtmlSnapshot {
|
||||
pub fn freeze(selection: SnapshotSelection) -> Result<Self, String> {
|
||||
let root = fs::canonicalize(&selection.root).map_err(|e| format!("snapshot root: {e}"))?;
|
||||
if !root.is_dir() {
|
||||
return Err("snapshot root is not a directory".into());
|
||||
}
|
||||
valid_relative(&selection.entry)?;
|
||||
if !matches!(
|
||||
Path::new(&selection.entry)
|
||||
.extension()
|
||||
.and_then(|s| s.to_str()),
|
||||
Some("html" | "htm")
|
||||
) {
|
||||
return Err(
|
||||
"snapshot requires an HTML entry; framework build binding is unsupported".into(),
|
||||
);
|
||||
}
|
||||
let served: BTreeSet<_> = selection.served.into_iter().collect();
|
||||
if !served.contains(&selection.entry) {
|
||||
return Err("selected entry is not served".into());
|
||||
}
|
||||
for name in &served {
|
||||
valid_relative(name)?;
|
||||
if name.split('/').any(|p| p.starts_with('.')) || mime(name).is_none() {
|
||||
return Err(format!("not an allowed static dependency: {name}"));
|
||||
}
|
||||
}
|
||||
let names: BTreeSet<_> = served.iter().cloned().chain(selection.bound).collect();
|
||||
if names.len() > MAX_FILES {
|
||||
return Err("snapshot exceeds file limit".into());
|
||||
}
|
||||
let mut files = BTreeMap::new();
|
||||
let mut total = 0;
|
||||
for name in names {
|
||||
let bytes = read_input(&root, &name)?;
|
||||
total += bytes.len();
|
||||
if total > MAX_TOTAL_BYTES {
|
||||
return Err("snapshot exceeds total byte limit".into());
|
||||
}
|
||||
files.insert(name, bytes);
|
||||
}
|
||||
let manifest = json!({"schema":"native-html-input-snapshot-v1","entry":selection.entry,
|
||||
"files":files.iter().map(|(name,bytes)|json!({"path":name,"sha256":hash(bytes),"bytes":bytes.len(),"served":served.contains(name)})).collect::<Vec<_>>()});
|
||||
let digest = hash(&serde_json::to_vec(&manifest).map_err(|e| e.to_string())?);
|
||||
let snapshot = Self {
|
||||
root,
|
||||
entry: selection.entry,
|
||||
served,
|
||||
files,
|
||||
manifest,
|
||||
digest,
|
||||
};
|
||||
snapshot.verify_current()?;
|
||||
Ok(snapshot)
|
||||
}
|
||||
/// Fresh in-process diagnostic evidence. Persisted receipts are deliberately not inputs.
|
||||
pub fn capture_region(
|
||||
self: &Arc<Self>,
|
||||
renderer: &mut dyn impeccable_comp_verbs::asset_capture::AssetRenderer,
|
||||
spec_path: &str,
|
||||
region_id: &str,
|
||||
reduced_motion: bool,
|
||||
) -> Result<impeccable_comp_verbs::asset_capture::AssetCapture, String> {
|
||||
self.capture_regions(renderer, spec_path, &[region_id], reduced_motion)?
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or_else(|| "capture returned no evidence".into())
|
||||
}
|
||||
/// Every requested region shares one frozen snapshot, server and browser document.
|
||||
pub fn capture_regions(
|
||||
self: &Arc<Self>,
|
||||
renderer: &mut dyn impeccable_comp_verbs::asset_capture::AssetRenderer,
|
||||
spec_path: &str,
|
||||
region_ids: &[&str],
|
||||
reduced_motion: bool,
|
||||
) -> Result<Vec<impeccable_comp_verbs::asset_capture::AssetCapture>, String> {
|
||||
self.capture_regions_at_viewport(renderer, spec_path, region_ids, reduced_motion, None)
|
||||
}
|
||||
/// Desktop/mobile captures retain the comp-owned region coordinates scaled
|
||||
/// to the requested width. Reflow fidelity remains a separate gate concern.
|
||||
pub fn capture_regions_at_viewport(
|
||||
self: &Arc<Self>,
|
||||
renderer: &mut dyn impeccable_comp_verbs::asset_capture::AssetRenderer,
|
||||
spec_path: &str,
|
||||
region_ids: &[&str],
|
||||
reduced_motion: bool,
|
||||
viewport: Option<[u32; 2]>,
|
||||
) -> Result<Vec<impeccable_comp_verbs::asset_capture::AssetCapture>, String> {
|
||||
use impeccable_comp_verbs::asset_capture::AssetCaptureRequest;
|
||||
self.verify_current()?;
|
||||
if region_ids.is_empty()
|
||||
|| region_ids.len() > 32
|
||||
|| region_ids.iter().copied().collect::<BTreeSet<_>>().len() != region_ids.len()
|
||||
{
|
||||
return Err("capture requires 1 to 32 distinct regions".into());
|
||||
}
|
||||
let spec: Value = serde_json::from_slice(
|
||||
self.bytes(spec_path)
|
||||
.ok_or("spec is not bound to snapshot")?,
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let reference = spec["comp"].as_str().ok_or("spec has no reference")?;
|
||||
let width = u32::try_from(
|
||||
spec["compSize"]["width"]
|
||||
.as_u64()
|
||||
.ok_or("missing reference width")?,
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let height = u32::try_from(
|
||||
spec["compSize"]["height"]
|
||||
.as_u64()
|
||||
.ok_or("missing reference height")?,
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let scale = viewport.map(|v| v[0] as f64 / width as f64).unwrap_or(1.);
|
||||
let [width, height] = viewport.unwrap_or([width, height]);
|
||||
let server = self.serve()?;
|
||||
let mut requests = Vec::new();
|
||||
for region_id in region_ids {
|
||||
let regions: Vec<_> = spec["regions"]
|
||||
.as_array()
|
||||
.ok_or("missing regions")?
|
||||
.iter()
|
||||
.filter(|r| r["id"] == *region_id)
|
||||
.collect();
|
||||
if regions.len() != 1 {
|
||||
return Err("region must resolve uniquely in bound spec".into());
|
||||
}
|
||||
let region = regions[0];
|
||||
let asset = region["plate"].as_str().ok_or("region has no asset path")?;
|
||||
if !self.served.contains(asset) {
|
||||
return Err("required asset is not served by snapshot".into());
|
||||
}
|
||||
let request = AssetCaptureRequest {
|
||||
url: server.entry_url(),
|
||||
viewport: [width, height],
|
||||
reduced_motion,
|
||||
expected_box: {
|
||||
let mut b: impeccable_comp_verbs::asset_capture::CaptureBox =
|
||||
serde_json::from_value(region["px"].clone()).map_err(|e| e.to_string())?;
|
||||
b.x *= scale;
|
||||
b.y *= scale;
|
||||
b.w *= scale;
|
||||
b.h *= scale;
|
||||
b
|
||||
},
|
||||
reference_bytes: self
|
||||
.bytes(reference)
|
||||
.ok_or("reference is not bound to snapshot")?
|
||||
.to_vec(),
|
||||
asset_bytes: self
|
||||
.bytes(asset)
|
||||
.ok_or("asset is not bound to snapshot")?
|
||||
.to_vec(),
|
||||
};
|
||||
request.validate()?;
|
||||
requests.push(request);
|
||||
}
|
||||
let mut captures = if requests.len() == 1 {
|
||||
vec![renderer.capture(&requests[0])?]
|
||||
} else {
|
||||
renderer.capture_batch(&requests)?
|
||||
};
|
||||
self.verify_current()?;
|
||||
if captures.len() != requests.len() {
|
||||
return Err("native capture omitted requested regions".into());
|
||||
}
|
||||
for ((capture, request), region_id) in captures.iter_mut().zip(&requests).zip(region_ids) {
|
||||
let receipt = &capture.receipt;
|
||||
if (receipt["status"] == "captured" || receipt["stableCapture"] == true)
|
||||
&& (receipt["resolvedUrl"] != request.url
|
||||
|| receipt["documentResponseSha256"] != hash(self.bytes(&self.entry).unwrap())
|
||||
|| receipt["assetSha256"] != hash(&request.asset_bytes)
|
||||
|| receipt["referenceSha256"] != hash(&request.reference_bytes)
|
||||
|| receipt["viewport"] != json!({"width":width,"height":height,"dpr":1})
|
||||
|| receipt["expectedBox"] != json!(request.expected_box)
|
||||
|| receipt["reducedMotion"] != reduced_motion)
|
||||
{
|
||||
return Err("native capture does not match frozen inputs and document".into());
|
||||
}
|
||||
capture.receipt["regionId"] = json!(region_id);
|
||||
capture.receipt["inputSnapshot"] = json!({"digest":self.digest,"manifest":self.manifest,"originalInputsVerified":true});
|
||||
}
|
||||
Ok(captures)
|
||||
}
|
||||
pub fn digest(&self) -> &str {
|
||||
&self.digest
|
||||
}
|
||||
pub fn manifest(&self) -> &Value {
|
||||
&self.manifest
|
||||
}
|
||||
pub fn entry(&self) -> &str {
|
||||
&self.entry
|
||||
}
|
||||
pub fn bytes(&self, name: &str) -> Option<&[u8]> {
|
||||
self.files.get(name).map(Vec::as_slice)
|
||||
}
|
||||
pub fn verify_current(&self) -> Result<(), String> {
|
||||
for (name, bytes) in &self.files {
|
||||
if read_input(&self.root, name)? != *bytes {
|
||||
return Err(format!("capture input changed: {name}"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// Strict origin-form routes. Decode percent-encoded UTF-8, but never separators.
|
||||
pub fn serve_path(&self, target: &str) -> Option<String> {
|
||||
let raw = target.strip_prefix('/')?.split('?').next()?;
|
||||
let mut bytes = Vec::new();
|
||||
let input = raw.as_bytes();
|
||||
let mut i = 0;
|
||||
while i < input.len() {
|
||||
if input[i] == b'%' {
|
||||
let hex = std::str::from_utf8(input.get(i + 1..i + 3)?).ok()?;
|
||||
let byte = u8::from_str_radix(hex, 16).ok()?;
|
||||
if matches!(byte, b'/' | b'\\' | 0) {
|
||||
return None;
|
||||
}
|
||||
bytes.push(byte);
|
||||
i += 3;
|
||||
} else {
|
||||
bytes.push(input[i]);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
let name = String::from_utf8(bytes).ok()?;
|
||||
valid_relative(&name).ok()?;
|
||||
self.served.contains(&name).then_some(name)
|
||||
}
|
||||
pub fn serve(self: &Arc<Self>) -> Result<SnapshotServer, String> {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").map_err(|e| e.to_string())?;
|
||||
let addr = listener.local_addr().map_err(|e| e.to_string())?;
|
||||
listener.set_nonblocking(true).map_err(|e| e.to_string())?;
|
||||
let host = addr.to_string();
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let worker_stop = stop.clone();
|
||||
let snapshot = self.clone();
|
||||
let worker_host = host.clone();
|
||||
let worker = thread::spawn(move || {
|
||||
while !worker_stop.load(Ordering::Acquire) {
|
||||
match listener.accept() {
|
||||
Ok((mut stream, _)) => {
|
||||
let _ = respond(&mut stream, &worker_host, &snapshot);
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
|
||||
thread::sleep(Duration::from_millis(5))
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
});
|
||||
Ok(SnapshotServer {
|
||||
host,
|
||||
entry: self.entry.clone(),
|
||||
stop,
|
||||
worker: Some(worker),
|
||||
})
|
||||
}
|
||||
}
|
||||
fn valid_relative(name: &str) -> Result<(), String> {
|
||||
if name.is_empty()
|
||||
|| name.contains(['\\', '\0', '?', '#', ':'])
|
||||
|| name
|
||||
.split('/')
|
||||
.any(|p| p.is_empty() || p == "." || p == "..")
|
||||
|| Path::new(name)
|
||||
.components()
|
||||
.any(|c| !matches!(c, Component::Normal(_)))
|
||||
{
|
||||
return Err(format!("invalid snapshot path: {name}"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn read_input(root: &Path, name: &str) -> Result<Vec<u8>, String> {
|
||||
valid_relative(name)?;
|
||||
let inspect = || -> Result<fs::Metadata, String> {
|
||||
if fs::symlink_metadata(root)
|
||||
.map_err(|e| e.to_string())?
|
||||
.file_type()
|
||||
.is_symlink()
|
||||
{
|
||||
return Err("snapshot root replaced by symlink".into());
|
||||
}
|
||||
let mut path = root.to_path_buf();
|
||||
for part in Path::new(name).components() {
|
||||
path.push(part);
|
||||
let m =
|
||||
fs::symlink_metadata(&path).map_err(|e| format!("snapshot input {name}: {e}"))?;
|
||||
if m.file_type().is_symlink() {
|
||||
return Err(format!("symlink snapshot input: {name}"));
|
||||
}
|
||||
}
|
||||
let m = fs::symlink_metadata(&path).map_err(|e| e.to_string())?;
|
||||
if !m.is_file() || m.len() > MAX_FILE_BYTES {
|
||||
return Err(format!("unsupported or oversized input: {name}"));
|
||||
}
|
||||
Ok(m)
|
||||
};
|
||||
let before = inspect()?;
|
||||
let mut file = File::open(root.join(name)).map_err(|e| e.to_string())?;
|
||||
let opened = file.metadata().map_err(|e| e.to_string())?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
if before.dev() != opened.dev() || before.ino() != opened.ino() {
|
||||
return Err(format!("input replaced while opening: {name}"));
|
||||
}
|
||||
}
|
||||
if !opened.is_file() || opened.len() > MAX_FILE_BYTES {
|
||||
return Err(format!("unsupported or oversized input: {name}"));
|
||||
}
|
||||
let mut bytes = Vec::new();
|
||||
(&mut file)
|
||||
.take(MAX_FILE_BYTES + 1)
|
||||
.read_to_end(&mut bytes)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let after = inspect()?;
|
||||
if bytes.len() as u64 > MAX_FILE_BYTES
|
||||
|| before.len() != after.len()
|
||||
|| before.modified().ok() != after.modified().ok()
|
||||
|| bytes.len() as u64 != after.len()
|
||||
{
|
||||
return Err(format!("input changed while reading: {name}"));
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
if before.dev() != after.dev() || before.ino() != after.ino() {
|
||||
return Err(format!("input replaced while reading: {name}"));
|
||||
}
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
fn mime(name: &str) -> Option<&'static str> {
|
||||
Some(match Path::new(name).extension()?.to_str()? {
|
||||
"html" | "htm" => "text/html; charset=utf-8",
|
||||
"css" => "text/css; charset=utf-8",
|
||||
"js" | "mjs" => "text/javascript; charset=utf-8",
|
||||
"png" => "image/png",
|
||||
"jpg" | "jpeg" => "image/jpeg",
|
||||
"webp" => "image/webp",
|
||||
"gif" => "image/gif",
|
||||
"svg" => "image/svg+xml",
|
||||
"avif" => "image/avif",
|
||||
"ico" => "image/x-icon",
|
||||
"woff" => "font/woff",
|
||||
"woff2" => "font/woff2",
|
||||
"ttf" => "font/ttf",
|
||||
"otf" => "font/otf",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
fn encode_path(path: &str) -> String {
|
||||
path.bytes()
|
||||
.map(|b| {
|
||||
if b.is_ascii_alphanumeric() || matches!(b, b'/' | b'-' | b'_' | b'.' | b'~') {
|
||||
(b as char).to_string()
|
||||
} else {
|
||||
format!("%{b:02X}")
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
pub struct SnapshotServer {
|
||||
host: String,
|
||||
entry: String,
|
||||
stop: Arc<AtomicBool>,
|
||||
worker: Option<JoinHandle<()>>,
|
||||
}
|
||||
impl SnapshotServer {
|
||||
pub fn entry_url(&self) -> String {
|
||||
format!("http://{}/{}", self.host, encode_path(&self.entry))
|
||||
}
|
||||
}
|
||||
impl Drop for SnapshotServer {
|
||||
fn drop(&mut self) {
|
||||
self.stop.store(true, Ordering::Release);
|
||||
if let Some(worker) = self.worker.take() {
|
||||
let _ = worker.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
fn respond(
|
||||
stream: &mut TcpStream,
|
||||
host: &str,
|
||||
snapshot: &HtmlSnapshot,
|
||||
) -> Result<(), std::io::Error> {
|
||||
// BSD/macOS can inherit O_NONBLOCK from the listening socket. Explicitly
|
||||
// switch accepted streams back before write_all; otherwise large bodies
|
||||
// stop at EWOULDBLOCK and appear as valid-header/truncated-image responses.
|
||||
stream.set_nonblocking(false)?;
|
||||
stream.set_read_timeout(Some(Duration::from_secs(2)))?;
|
||||
stream.set_write_timeout(Some(Duration::from_secs(2)))?;
|
||||
let mut bytes = Vec::new();
|
||||
let mut buf = [0u8; 1024];
|
||||
while bytes.len() <= 8192 && !bytes.windows(4).any(|x| x == b"\r\n\r\n") {
|
||||
let n = stream.read(&mut buf)?;
|
||||
if n == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
bytes.extend_from_slice(&buf[..n]);
|
||||
}
|
||||
let request = std::str::from_utf8(&bytes).unwrap_or("");
|
||||
let mut lines = request.split("\r\n");
|
||||
let mut first = lines.next().unwrap_or("").split_whitespace();
|
||||
let method = first.next();
|
||||
let target = first.next();
|
||||
let protocol = first.next();
|
||||
let hosts: Vec<_> = lines
|
||||
.filter_map(|line| line.split_once(':'))
|
||||
.filter(|(key, _)| key.eq_ignore_ascii_case("host"))
|
||||
.map(|(_, value)| value.trim())
|
||||
.collect();
|
||||
let valid = bytes.len() <= 8192
|
||||
&& method == Some("GET")
|
||||
&& protocol == Some("HTTP/1.1")
|
||||
&& first.next().is_none()
|
||||
&& hosts == [host];
|
||||
let route = if valid {
|
||||
target.and_then(|t| snapshot.serve_path(t))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let (status, kind, body) = match route.as_deref() {
|
||||
Some(name) => ("200 OK", mime(name).unwrap(), snapshot.bytes(name).unwrap()),
|
||||
None => ("404 Not Found", "text/plain", b"Not found".as_slice()),
|
||||
};
|
||||
write!(
|
||||
stream,
|
||||
"HTTP/1.1 {status}\r\nContent-Type: {kind}\r\nContent-Length: {}\r\nConnection: close\r\nCache-Control: private, max-age=3600, immutable\r\nX-Content-Type-Options: nosniff\r\n\r\n",
|
||||
body.len()
|
||||
)?;
|
||||
stream.write_all(body)
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
//! Native component previews over the shared frozen-input/CDP capture foundation.
|
||||
//! This records provenance and pixels; visual acceptance remains a separate human decision.
|
||||
use base64::Engine;
|
||||
use impeccable_browser::{
|
||||
cdp::{Browser, Viewport},
|
||||
discovery,
|
||||
html_snapshot::HtmlSnapshot,
|
||||
};
|
||||
use impeccable_context::component_review::capture::{CapturedPreviews, ComponentCapturer};
|
||||
use serde_json::{Value, json};
|
||||
use std::{collections::BTreeMap, sync::Arc, time::Duration};
|
||||
|
||||
pub struct NativeComponentCapturer;
|
||||
fn hash(bytes: &[u8]) -> String {
|
||||
// The PNG module and review store use the same SHA-256; avoid a second hash contract.
|
||||
use sha2::{Digest, Sha256};
|
||||
format!("{:x}", Sha256::digest(bytes))
|
||||
}
|
||||
fn source(view: &Value) -> Result<&str, String> {
|
||||
view["url"]
|
||||
.as_str()
|
||||
.and_then(|s| s.strip_prefix("/files/"))
|
||||
.ok_or_else(|| "preview needs a pinned source".into())
|
||||
}
|
||||
fn material(png: &[u8], format: &str) -> Result<Value, String> {
|
||||
if !impeccable_comp::png_io::is_png(png) || png.len() < 24 {
|
||||
return Err("native review currently requires PNG raster assets".into());
|
||||
}
|
||||
let width = u32::from_be_bytes(png[16..20].try_into().unwrap());
|
||||
let height = u32::from_be_bytes(png[20..24].try_into().unwrap());
|
||||
if u64::from(width) * u64::from(height) > 32_000_000 {
|
||||
return Err("preview exceeds 32 megapixels".into());
|
||||
}
|
||||
let image = impeccable_comp::png_io::decode_png(png)?.image;
|
||||
Ok(
|
||||
json!({"format":format,"width":image.width,"height":image.height,"alpha":if image.data.chunks_exact(4).any(|p|p[3]<255){"transparent"}else{"opaque"}}),
|
||||
)
|
||||
}
|
||||
fn render_page(
|
||||
browser: &mut Browser,
|
||||
snapshot: Arc<HtmlSnapshot>,
|
||||
width: u32,
|
||||
height: u32,
|
||||
box_: &Value,
|
||||
) -> Result<(Vec<u8>, Value), String> {
|
||||
let server = snapshot.serve()?;
|
||||
let url = server.entry_url();
|
||||
let origin = url.split('/').take(3).collect::<Vec<_>>().join("/");
|
||||
let mut page = browser.new_page().map_err(|e| e.message)?;
|
||||
let result = (|| {
|
||||
page.set_viewport(Viewport { width, height })
|
||||
.map_err(|e| e.message)?;
|
||||
page.set_reduced_motion(true).map_err(|e| e.message)?;
|
||||
page.begin_response_capture().map_err(|e| e.message)?;
|
||||
page.goto(&url, "networkidle0", Duration::from_secs(20))
|
||||
.map_err(|e| e.message)?;
|
||||
let world = page.create_isolated_world().map_err(|e| e.message)?;
|
||||
let dom=page.evaluate_value_in_world(&world,r#"(async()=>{
|
||||
if(document.scripts.length||document.querySelector('iframe,frame,object,embed,canvas'))throw Error('Component capture requires static HTML/CSS/SVG; script, frame and canvas components need a supported capture adapter.');
|
||||
await Promise.race([(async()=>{await document.fonts.ready;await Promise.all([...document.images].map(i=>i.decode()));})(),new Promise((_,reject)=>setTimeout(()=>reject(Error('component resources did not settle')),5000))]);
|
||||
if([...document.fonts].some(f=>f.status==='error'))throw Error('A component font failed to load.');
|
||||
if(document.getAnimations().some(a=>a.playState==='running'))throw Error('Component is animated; provide its static review state.');
|
||||
return {html:document.documentElement.outerHTML,svg:document.querySelectorAll('svg').length,images:document.images.length,controls:document.querySelectorAll('button,input,select,textarea,a[href]').length};
|
||||
})()"#).map_err(|e|e.message)?;
|
||||
let coords = ["x", "y", "w", "h"].map(|k| box_[k].as_f64().unwrap());
|
||||
let clip = [
|
||||
coords[0] * width as f64,
|
||||
coords[1] * height as f64,
|
||||
coords[2] * width as f64,
|
||||
coords[3] * height as f64,
|
||||
];
|
||||
let first = page
|
||||
.screenshot_clip(clip[0], clip[1], clip[2], clip[3])
|
||||
.map_err(|e| e.message)?;
|
||||
let urls = page.observed_response_urls().map_err(|e| e.message)?;
|
||||
let evidence = page.response_evidence(&urls).map_err(|e| e.message)?;
|
||||
if evidence.truncated
|
||||
|| evidence.changed_during_collection
|
||||
|| !evidence.missing_urls.is_empty()
|
||||
{
|
||||
return Err("component network evidence is incomplete".into());
|
||||
}
|
||||
let mut responses = BTreeMap::new();
|
||||
for record in &evidence.responses {
|
||||
if record.url.starts_with("data:image/") {
|
||||
continue;
|
||||
}
|
||||
if record.url == format!("{origin}/favicon.ico")
|
||||
&& record.status == Some(404.)
|
||||
&& snapshot.bytes("favicon.ico").is_none()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let target = record
|
||||
.url
|
||||
.strip_prefix(&origin)
|
||||
.ok_or("component requested an external dependency")?;
|
||||
let path = snapshot
|
||||
.serve_path(target)
|
||||
.ok_or_else(|| format!("undeclared component dependency: {target}"))?;
|
||||
let expected = snapshot.bytes(&path).ok_or("missing frozen dependency")?;
|
||||
if record.status != Some(200.)
|
||||
|| !record.complete
|
||||
|| record.from_service_worker
|
||||
|| record.ambiguous_url
|
||||
|| record.body.as_deref() != Some(expected)
|
||||
{
|
||||
return Err(format!(
|
||||
"component dependency did not match frozen bytes: {path}"
|
||||
));
|
||||
}
|
||||
responses.insert(path, hash(expected));
|
||||
}
|
||||
if !responses.contains_key(snapshot.entry()) {
|
||||
return Err("component document response is unverified".into());
|
||||
}
|
||||
let second = page
|
||||
.screenshot_clip(clip[0], clip[1], clip[2], clip[3])
|
||||
.map_err(|e| e.message)?;
|
||||
let after = page.response_evidence(&urls).map_err(|e| e.message)?;
|
||||
if first != second || after.revision != evidence.revision || after.changed_during_collection
|
||||
{
|
||||
return Err("component changed during capture".into());
|
||||
}
|
||||
// Identity comes from the isolated document, never a producer-written receipt.
|
||||
let unchanged = page
|
||||
.evaluate_value_in_world(&world, "document.documentElement.outerHTML")
|
||||
.map_err(|e| e.message)?;
|
||||
if unchanged != dom["html"] {
|
||||
return Err("component document changed during capture".into());
|
||||
}
|
||||
let png = base64::engine::general_purpose::STANDARD
|
||||
.decode(first)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let proof = json!({"kind":"static-code","entry":snapshot.entry(),"inputSnapshot":snapshot.digest(),"inputs":snapshot.manifest(),"observedDependencies":responses,"domSha256":hash(dom["html"].as_str().unwrap().as_bytes()),"screenshotSha256":hash(&png),"viewport":{"width":width,"height":height,"dpr":1},"box":box_,"reducedMotion":true,"svgElements":dom["svg"],"rasterElements":dom["images"],"semanticControls":dom["controls"]});
|
||||
Ok((png, proof))
|
||||
})();
|
||||
page.close();
|
||||
result
|
||||
}
|
||||
impl ComponentCapturer for NativeComponentCapturer {
|
||||
fn capture(
|
||||
&mut self,
|
||||
packet: &mut Value,
|
||||
inputs: &BTreeMap<String, Vec<u8>>,
|
||||
) -> Result<CapturedPreviews, String> {
|
||||
let width = packet["comp"]["width"]
|
||||
.as_u64()
|
||||
.ok_or("missing comp width")? as u32;
|
||||
let height = packet["comp"]["height"]
|
||||
.as_u64()
|
||||
.ok_or("missing comp height")? as u32;
|
||||
if u64::from(width) * u64::from(height) > 16_000_000 {
|
||||
return Err("component viewport exceeds 16 megapixels".into());
|
||||
}
|
||||
let reference = inputs
|
||||
.get(source(&packet["comp"])?)
|
||||
.ok_or("missing approved reference")?;
|
||||
let reference_size = material(reference, "PNG")?;
|
||||
if reference_size["width"] != width || reference_size["height"] != height {
|
||||
return Err("comp dimensions do not match its PNG".into());
|
||||
}
|
||||
let env = std::env::vars().collect();
|
||||
let exe =
|
||||
discovery::find_browser(&env).map_err(|e| format!("browser unavailable: {e:?}"))?;
|
||||
let mut browser = Browser::launch(&exe, &[], false).map_err(|e| e.message)?;
|
||||
let version = browser.version().map_err(|e| e.message)?;
|
||||
let result = (|| {
|
||||
let mut files = BTreeMap::new();
|
||||
let mut evidence = Vec::new();
|
||||
// Reuse captures across regions sharing a source and geometry, never across changed inputs.
|
||||
let mut cache: BTreeMap<String, (Vec<u8>, Value)> = BTreeMap::new();
|
||||
for c in packet["components"]
|
||||
.as_array_mut()
|
||||
.ok_or("missing components")?
|
||||
{
|
||||
let id = c["id"].as_str().ok_or("missing component id")?.to_string();
|
||||
let mut views = serde_json::Map::new();
|
||||
for key in ["preview", "context"] {
|
||||
if c.get(key).is_none() {
|
||||
continue;
|
||||
}
|
||||
let path = source(&c[key])?.to_string();
|
||||
if key == "preview" && c[key]["kind"] == "image" {
|
||||
let bytes = inputs.get(&path).ok_or("missing raster source")?;
|
||||
c["material"] = material(bytes, "PNG")?;
|
||||
views.insert(
|
||||
key.into(),
|
||||
json!({"kind":"raster-source","path":path,"sha256":hash(bytes)}),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
let mut selected = BTreeMap::new();
|
||||
for name in c["dependencies"]
|
||||
.as_array()
|
||||
.ok_or("missing dependencies")?
|
||||
.iter()
|
||||
.filter_map(Value::as_str)
|
||||
.chain(std::iter::once(path.as_str()))
|
||||
{
|
||||
selected.insert(
|
||||
name.into(),
|
||||
inputs.get(name).ok_or("missing pinned dependency")?.clone(),
|
||||
);
|
||||
}
|
||||
let snapshot = Arc::new(HtmlSnapshot::from_pinned(path.clone(), selected)?);
|
||||
let cache_key = format!("{}:{}", snapshot.digest(), c["box"]);
|
||||
let (png, proof) = if let Some(saved) = cache.get(&cache_key) {
|
||||
saved.clone()
|
||||
} else {
|
||||
let captured =
|
||||
render_page(&mut browser, snapshot, width, height, &c["box"])
|
||||
.map_err(|e| format!("{id} {key}: {e}"))?;
|
||||
cache.insert(cache_key, captured.clone());
|
||||
captured
|
||||
};
|
||||
let output = format!("_review_captures/{}.png", hash(&png));
|
||||
c[key]["url"] = json!(format!("/files/{output}"));
|
||||
c[key]["kind"] = json!("image");
|
||||
c[key]["sourceKind"] = json!("page");
|
||||
if key == "preview" {
|
||||
c["material"] = material(&png, "Captured HTML / CSS / SVG")?;
|
||||
}
|
||||
views.insert(key.into(), proof);
|
||||
files.insert(output, png);
|
||||
}
|
||||
// Thumbnails must show exactly the reviewable output, not a separate producer image.
|
||||
c["thumbnail"] = json!({"url":c["preview"]["url"]});
|
||||
evidence.push(json!({"id":id,"views":views}));
|
||||
}
|
||||
Ok(CapturedPreviews {
|
||||
files,
|
||||
evidence: json!({"schema":"native-component-previews-v1","browser":version,"components":evidence,"scope":"Pinned raster sources and static HTML/CSS/SVG captures. No visual, semantic or human-identity approval."}),
|
||||
})
|
||||
})();
|
||||
browser.close();
|
||||
result
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
//! Native static-entry adapter. One immutable snapshot; batches share a browser per viewport.
|
||||
use crate::{
|
||||
asset_capture::CdpAssetRenderer,
|
||||
capture_snapshot::{HtmlSnapshot, SnapshotSelection},
|
||||
};
|
||||
use impeccable_comp_verbs::entry_capture::{
|
||||
CapturedEntry, EntryEvidence, EntryRenderer, EntryRequest, EntryStage, FrameEvidence,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use std::{fs, path::Path, sync::Arc};
|
||||
|
||||
pub struct CdpEntryRenderer;
|
||||
struct FrozenEntry {
|
||||
snapshot: Arc<HtmlSnapshot>,
|
||||
evidence: EntryEvidence,
|
||||
}
|
||||
impl CapturedEntry for FrozenEntry {
|
||||
fn evidence(&self) -> &EntryEvidence {
|
||||
&self.evidence
|
||||
}
|
||||
fn verify_current(&self) -> Result<(), String> {
|
||||
self.snapshot.verify_current()
|
||||
}
|
||||
}
|
||||
impl EntryRenderer for CdpEntryRenderer {
|
||||
fn capture_entry(&self, request: &EntryRequest) -> Result<Box<dyn CapturedEntry>, String> {
|
||||
// The shared gate chooses the entry/spec/reference, never a caller URL.
|
||||
let served = static_inventory(&request.root)?;
|
||||
let snapshot = Arc::new(HtmlSnapshot::freeze(SnapshotSelection {
|
||||
root: request.root.clone(),
|
||||
entry: request.artifact.clone(),
|
||||
served,
|
||||
bound: vec![request.spec.clone(), request.reference.clone()],
|
||||
})?);
|
||||
let spec: Value =
|
||||
serde_json::from_slice(snapshot.bytes(&request.spec).ok_or("missing bound spec")?)
|
||||
.map_err(|e| e.to_string())?;
|
||||
if spec["comp"] != request.reference {
|
||||
return Err("state and spec disagree on the approved reference".into());
|
||||
}
|
||||
let ids: Vec<_> = spec["regions"]
|
||||
.as_array()
|
||||
.ok_or("missing spec regions")?
|
||||
.iter()
|
||||
.filter(|r| r["medium"] == "raster")
|
||||
.map(|r| r["id"].as_str().ok_or("raster region missing id"))
|
||||
.collect::<Result<_, _>>()?;
|
||||
if ids.is_empty() {
|
||||
return Err("native raster capture requires at least one raster region; text-only capture is not supported yet".into());
|
||||
}
|
||||
let width = spec["compSize"]["width"]
|
||||
.as_f64()
|
||||
.ok_or("missing reference width")?;
|
||||
let height = spec["compSize"]["height"]
|
||||
.as_f64()
|
||||
.ok_or("missing reference height")?;
|
||||
if width <= 0. || height <= 0. {
|
||||
return Err("invalid reference dimensions".into());
|
||||
}
|
||||
let frames: Vec<(&str, Option<[u32; 2]>)> = match request.stage {
|
||||
EntryStage::Hero => vec![("hero", None)],
|
||||
EntryStage::Responsive => vec![
|
||||
(
|
||||
"desktop",
|
||||
Some([1440, (height * 1440. / width).ceil() as u32]),
|
||||
),
|
||||
(
|
||||
"mobile",
|
||||
Some([390, 844.max((height * 390. / width).ceil() as u32)]),
|
||||
),
|
||||
],
|
||||
};
|
||||
let mut evidence = EntryEvidence {
|
||||
report: json!({"schema":"native-entry-capture-v1","inputSnapshot":snapshot.digest(),"manifest":snapshot.manifest(),"artifact":request.artifact,"stage":match request.stage {EntryStage::Hero=>"hero",EntryStage::Responsive=>"responsive"},"scope":"Fresh static HTML rendering and scoped raster evidence. No independent aesthetic approval."}),
|
||||
frames: vec![],
|
||||
};
|
||||
for (name, viewport) in frames {
|
||||
// Mobile is captured as actual page evidence; the desktop comp does
|
||||
// not prescribe mobile artwork positions. Do not score its placements.
|
||||
let selected = if name == "mobile" {
|
||||
&ids[..1]
|
||||
} else {
|
||||
&ids[..]
|
||||
};
|
||||
let regions = snapshot.capture_regions_at_viewport(
|
||||
&mut CdpAssetRenderer::from_process_env(),
|
||||
&request.spec,
|
||||
selected,
|
||||
true,
|
||||
viewport,
|
||||
)?;
|
||||
if regions.iter().any(|r| {
|
||||
r.receipt["stableCapture"] != true || r.receipt["batchStabilityVerified"] != true
|
||||
}) {
|
||||
let details = regions
|
||||
.iter()
|
||||
.filter(|r| {
|
||||
r.receipt["stableCapture"] != true
|
||||
|| r.receipt["batchStabilityVerified"] != true
|
||||
})
|
||||
.take(4)
|
||||
.map(|r| {
|
||||
format!(
|
||||
"{}: {}",
|
||||
r.receipt["regionId"].as_str().unwrap_or("region"),
|
||||
r.receipt["individualCaptureReason"]
|
||||
.as_str()
|
||||
.or_else(|| r.receipt["reason"].as_str())
|
||||
.unwrap_or("capture identity changed")
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("; ");
|
||||
return Err(format!(
|
||||
"{name} native capture did not retain a stable bound document: {details}"
|
||||
));
|
||||
}
|
||||
let png = regions[0]
|
||||
.images
|
||||
.iter()
|
||||
.find(|i| i.name == "baseline.png")
|
||||
.ok_or("native capture has no baseline image")?
|
||||
.png
|
||||
.clone();
|
||||
evidence.frames.push(FrameEvidence {
|
||||
name: name.into(),
|
||||
png,
|
||||
regions,
|
||||
});
|
||||
}
|
||||
snapshot.verify_current()?;
|
||||
Ok(Box::new(FrozenEntry { snapshot, evidence }))
|
||||
}
|
||||
}
|
||||
|
||||
/// Enumerate only static browser files. Never serve hidden state, source-only
|
||||
/// extensions or package trees. A bound/required file omitted here is an error.
|
||||
pub fn static_inventory(root: &Path) -> Result<Vec<String>, String> {
|
||||
fn walk(
|
||||
root: &Path,
|
||||
dir: &Path,
|
||||
depth: usize,
|
||||
visited: &mut usize,
|
||||
out: &mut Vec<String>,
|
||||
) -> Result<(), String> {
|
||||
if depth > 12 {
|
||||
return Err("static input inventory exceeds directory-depth budget".into());
|
||||
}
|
||||
let mut entries = fs::read_dir(dir)
|
||||
.map_err(|e| e.to_string())?
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|e| e.to_string())?;
|
||||
entries.sort_by_key(|e| e.file_name());
|
||||
for entry in entries {
|
||||
*visited += 1;
|
||||
if *visited > 8192 {
|
||||
return Err("static input inventory exceeds entry budget".into());
|
||||
}
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
if name.starts_with('.') || name == "node_modules" {
|
||||
continue;
|
||||
}
|
||||
let kind = entry.file_type().map_err(|e| e.to_string())?;
|
||||
if kind.is_symlink() {
|
||||
return Err(format!(
|
||||
"symlink in static input tree: {}",
|
||||
entry.path().display()
|
||||
));
|
||||
}
|
||||
if kind.is_dir() {
|
||||
walk(root, &entry.path(), depth + 1, visited, out)?;
|
||||
} else if kind.is_file()
|
||||
&& matches!(
|
||||
entry.path().extension().and_then(|x| x.to_str()),
|
||||
Some(
|
||||
"html"
|
||||
| "htm"
|
||||
| "css"
|
||||
| "js"
|
||||
| "mjs"
|
||||
| "png"
|
||||
| "jpg"
|
||||
| "jpeg"
|
||||
| "webp"
|
||||
| "gif"
|
||||
| "svg"
|
||||
| "avif"
|
||||
| "ico"
|
||||
| "woff"
|
||||
| "woff2"
|
||||
| "ttf"
|
||||
| "otf"
|
||||
)
|
||||
)
|
||||
{
|
||||
out.push(
|
||||
entry
|
||||
.path()
|
||||
.strip_prefix(root)
|
||||
.map_err(|e| e.to_string())?
|
||||
.to_str()
|
||||
.ok_or("non-UTF8 static path")?
|
||||
.replace('\\', "/"),
|
||||
);
|
||||
if out.len() > 1024 {
|
||||
return Err("static input inventory exceeds file budget".into());
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
let root = fs::canonicalize(root).map_err(|e| e.to_string())?;
|
||||
let mut out = Vec::new();
|
||||
walk(&root, &root, 0, &mut 0, &mut out)?;
|
||||
Ok(out)
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
//! Native browser adapters, kept separate from the comp-verbs contract.
|
||||
pub mod asset_capture;
|
||||
|
||||
pub mod capture_snapshot;
|
||||
|
||||
pub mod entry_capture;
|
||||
|
||||
pub mod capture_service;
|
||||
+10
-1
@@ -12,6 +12,7 @@ use std::io::Write;
|
||||
use impeccable_common::Io;
|
||||
|
||||
mod font_render;
|
||||
mod component_capture;
|
||||
|
||||
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
|
||||
@@ -66,6 +67,7 @@ fn run(args: &[String], io: &mut Io) -> i32 {
|
||||
"concept-seed" => impeccable_context::run_concept_seed(rest, io),
|
||||
"generate-image" => impeccable_context::run_generate_image(rest, io),
|
||||
"serve-question" => impeccable_context::run_serve_question(rest, io),
|
||||
"component-review" => impeccable_context::component_review::run_with_capturer(rest, io, Some(&mut component_capture::NativeComponentCapturer)),
|
||||
// comp-fidelity verbs (crates/comp-verbs over crates/comp)
|
||||
"comp-spec" => impeccable_comp_verbs::run_comp_spec(rest, io),
|
||||
"comp-diff" => impeccable_comp_verbs::run_comp_diff(rest, io),
|
||||
@@ -73,6 +75,9 @@ fn run(args: &[String], io: &mut Io) -> i32 {
|
||||
let mut renderer = font_render::CdpFontRenderer::from_process_env();
|
||||
impeccable_comp_verbs::run_font_match(rest, io, &mut renderer)
|
||||
}
|
||||
"capture-server" => {
|
||||
match impeccable::capture_service::serve(rest) { Ok(()) => 0, Err(e) => { io.err(&format!("Native capture service: {e}\n")); 1 } }
|
||||
}
|
||||
"build-phase" => {
|
||||
// Inject the organic-clip-path CSS scanner (a rule that lives in the
|
||||
// closed `core` crate) so comp-verbs stays core-free.
|
||||
@@ -82,7 +87,11 @@ fn run(args: &[String], io: &mut Io) -> i32 {
|
||||
.map(|f| (f.selector, f.snippet))
|
||||
.collect()
|
||||
};
|
||||
impeccable_comp_verbs::run_build_phase(rest, io, &organic)
|
||||
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)),
|
||||
Err(e) => { io.err(&format!("Native capture service: {e}\n")); 1 }
|
||||
}
|
||||
}
|
||||
"hook" => impeccable_hook::run_hook(rest, io, engines().html),
|
||||
"hook-before-edit" => impeccable_hook::run_hook_before_edit(rest, io, engines().html),
|
||||
|
||||
Reference in New Issue
Block a user