diff --git a/.gitleaksignore b/.gitleaksignore index c2ace1f..cf5b8f2 100644 --- a/.gitleaksignore +++ b/.gitleaksignore @@ -1,4 +1,5 @@ # Synthetic secret fixtures retained in Git history; tracked in pickforge/picklab#60. +01c7e60369b9e3a415218a6b23a397c2eeb9abd4:crates/pickforge-cli/tests/evidence.rs:generic-api-key:184 de467fbd14d00d4afa4946f610d05d724a6fa777:packages/browser/test/evidence-integration.test.ts:jwt:27 d3e4353d8fbd470deaaab30c61c7fdb0c5b14727:packages/browser/test/evidence-integration.test.ts:jwt:27 596c7c21cd5472c99db0f516988b5483354e2fe7:packages/core/test/redact.test.ts:jwt:258 diff --git a/Cargo.lock b/Cargo.lock index b2a9b93..804341d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,15 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + [[package]] name = "anstream" version = "1.0.0" @@ -164,6 +173,12 @@ dependencies = [ "typenum", ] +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + [[package]] name = "difflib" version = "0.4.0" @@ -316,6 +331,12 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + [[package]] name = "once_cell" version = "1.21.4" @@ -341,16 +362,25 @@ dependencies = [ "assert_cmd", "clap", "directories", + "regex", "serde", "serde_json", "serde_yaml_ng", "sha2", "tempfile", "thiserror", + "time", "toml", "which", + "windows-sys", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "predicates" version = "3.1.4" @@ -413,11 +443,34 @@ dependencies = [ "thiserror", ] +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + [[package]] name = "regex-automata" version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "rustix" @@ -571,6 +624,36 @@ dependencies = [ "syn", ] +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "toml" version = "1.1.4+spec-1.1.0" diff --git a/crates/pickforge-cli/Cargo.toml b/crates/pickforge-cli/Cargo.toml index 58f4b72..a5c12e1 100644 --- a/crates/pickforge-cli/Cargo.toml +++ b/crates/pickforge-cli/Cargo.toml @@ -21,10 +21,15 @@ serde = { version = "1", features = ["derive"] } serde_json = { version = "1", features = ["preserve_order"] } serde_yaml_ng = "0.10" sha2 = "0.10" +regex = "1" thiserror = "2" +time = { version = "0.3", features = ["formatting", "macros"] } tempfile = "3" toml = { version = "1.1", default-features = false, features = ["parse", "serde", "std"] } which = "8" +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.61.2", features = ["Win32_Foundation", "Win32_Storage_FileSystem"] } + [dev-dependencies] assert_cmd = "2" diff --git a/crates/pickforge-cli/assets/skills/pickforge-flutter/SKILL.md b/crates/pickforge-cli/assets/skills/pickforge-flutter/SKILL.md index 96c95b3..a0c0b2f 100644 --- a/crates/pickforge-cli/assets/skills/pickforge-flutter/SKILL.md +++ b/crates/pickforge-cli/assets/skills/pickforge-flutter/SKILL.md @@ -19,8 +19,18 @@ description: >- `flutter_driver`, or enable the driver extension. Ask first if any of those are necessary. 4. Run scoped analysis and tests for the changed source, then use hot reload. -5. Repeat the same runtime scenario and capture before/after evidence. Hand off - the source mapping, change, checks, and observed result. +5. Repeat the same runtime scenario and capture before/after evidence. Review + every screenshot first: Pickforge cannot redact secret or private pixels. +6. Supply the complete bounded envelope and absolute image paths in one call: + + ```sh + pickforge evidence record --project-dir "$PWD" --input - <<'JSON' + {"schemaVersion":1,"scenario":"Counter increments after hot reload","outcome":"passed","before":{"summary":"Counter stayed at zero.","observations":[],"artifacts":[]},"after":{"summary":"Counter changed to one.","observations":[{"label":"Counter","value":"1"}],"artifacts":[]},"sourceChanges":["lib/main.dart"],"checks":[{"name":"flutter test","status":"passed","summary":"Focused tests passed."}],"limitations":[]} + JSON + ``` + + Pickforge only validates and records this envelope; it never invokes MCP, + Flutter, Dart, Git, network tools, or screenshot capture. If an MCP tool, resource, runtime, or hot-reload capability is unavailable, name that exact capability and run `pickforge doctor`; never fabricate evidence. diff --git a/crates/pickforge-cli/src/adapters.rs b/crates/pickforge-cli/src/adapters.rs index d2732b3..9642535 100644 --- a/crates/pickforge-cli/src/adapters.rs +++ b/crates/pickforge-cli/src/adapters.rs @@ -106,7 +106,7 @@ impl IntegrationPack { pub fn flutter() -> Self { Self { name: "pickforge-flutter".into(), - version: 1, + version: 2, mcp_servers: vec![McpServerSpec { name: "pickforge-dart".into(), command: "dart".into(), diff --git a/crates/pickforge-cli/src/env.rs b/crates/pickforge-cli/src/env.rs index 9a235b1..a213148 100644 --- a/crates/pickforge-cli/src/env.rs +++ b/crates/pickforge-cli/src/env.rs @@ -19,8 +19,28 @@ fn normalize_key(key: String) -> String { } } -/// The ambient inputs `doctor` is allowed to read: environment variables and -/// the user's home directory. +fn collect_vars( + vars: impl IntoIterator, +) -> BTreeMap { + vars.into_iter() + .filter_map(|(key, value)| Some((normalize_key(key.into_string().ok()?), value))) + .collect() +} + +fn home_from_vars(vars: &BTreeMap) -> Option { + #[cfg(windows)] + const HOME_KEYS: [&str; 2] = ["USERPROFILE", "HOME"]; + #[cfg(not(windows))] + const HOME_KEYS: [&str; 1] = ["HOME"]; + + HOME_KEYS.iter().find_map(|key| { + let path = PathBuf::from(vars.get(*key)?); + path.is_absolute().then_some(path) + }) +} + +/// The ambient inputs CLI commands are allowed to read: environment variables +/// and the user's home directory. #[derive(Debug, Clone, Default)] pub struct Environment { vars: BTreeMap, @@ -30,12 +50,10 @@ pub struct Environment { impl Environment { /// The real process environment. pub fn from_process() -> Self { - Self { - vars: std::env::vars_os() - .filter_map(|(key, value)| Some((normalize_key(key.into_string().ok()?), value))) - .collect(), - home_dir: directories::BaseDirs::new().map(|dirs| dirs.home_dir().to_path_buf()), - } + let vars = collect_vars(std::env::vars_os()); + let home_dir = home_from_vars(&vars) + .or_else(|| directories::BaseDirs::new().map(|dirs| dirs.home_dir().to_path_buf())); + Self { vars, home_dir } } /// An environment with no variables and no home directory. @@ -68,3 +86,45 @@ impl Environment { self.home_dir.as_deref() } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn process_home_requires_an_absolute_path() { + let mut vars = BTreeMap::new(); + vars.insert(normalize_key("HOME".into()), OsString::from("relative")); + assert_eq!(home_from_vars(&vars), None); + + #[cfg(windows)] + let absolute = PathBuf::from(r"C:\isolated-home"); + #[cfg(not(windows))] + let absolute = PathBuf::from("/isolated-home"); + vars.insert(normalize_key("HOME".into()), absolute.clone().into()); + assert_eq!(home_from_vars(&vars), Some(absolute)); + } + + #[cfg(windows)] + #[test] + fn userprofile_precedes_home_on_windows() { + let profile = PathBuf::from(r"C:\profile"); + let home = PathBuf::from(r"D:\home"); + let vars = collect_vars([ + (OsString::from("UserProfile"), profile.clone().into()), + (OsString::from("Home"), home.into()), + ]); + assert_eq!(home_from_vars(&vars), Some(profile)); + } + + #[cfg(windows)] + #[test] + fn relative_userprofile_falls_through_to_home() { + let home = PathBuf::from(r"D:\home"); + let vars = collect_vars([ + (OsString::from("UserProfile"), OsString::from("relative")), + (OsString::from("Home"), home.clone().into()), + ]); + assert_eq!(home_from_vars(&vars), Some(home)); + } +} diff --git a/crates/pickforge-cli/src/evidence.rs b/crates/pickforge-cli/src/evidence.rs new file mode 100644 index 0000000..6f586d0 --- /dev/null +++ b/crates/pickforge-cli/src/evidence.rs @@ -0,0 +1,1030 @@ +//! Single-shot, external Flutter evidence recording. + +use std::collections::BTreeMap; +use std::fs::{self, File, OpenOptions}; +use std::io::{self, Read, Write}; +use std::path::{Component, Path, PathBuf}; +use std::sync::OnceLock; +use std::time::SystemTime; + +use regex::Regex; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use thiserror::Error; +use time::{format_description, OffsetDateTime}; + +use crate::{adapters::Harness, init::INIT_SCHEMA_VERSION, project, state, Environment}; + +pub const EVIDENCE_SCHEMA_VERSION: u32 = 1; +pub const MAX_INPUT_BYTES: u64 = 1024 * 1024; +const MAX_IMAGE_BYTES: u64 = 8 * 1024 * 1024; +const MAX_TOTAL_IMAGE_BYTES: u64 = 64 * 1024 * 1024; +const MAX_RUN_ID_ATTEMPTS: usize = 1024; + +#[derive(Debug, Clone, Copy, Deserialize, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum Outcome { + Passed, + Failed, + Inconclusive, +} + +#[derive(Debug, Clone, Copy, Deserialize, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum CheckStatus { + Passed, + Failed, + Skipped, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct EvidenceInput { + schema_version: u32, + scenario: String, + outcome: Outcome, + before: PhaseInput, + after: PhaseInput, + #[serde(default)] + source_changes: Vec, + #[serde(default)] + checks: Vec, + #[serde(default)] + limitations: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +struct PhaseInput { + summary: String, + #[serde(default)] + observations: Vec, + #[serde(default)] + artifacts: Vec, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct Observation { + label: String, + value: String, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +struct ArtifactInput { + kind: ArtifactKind, + label: String, + source: PathBuf, +} + +#[derive(Debug, Clone, Copy, Deserialize)] +#[serde(rename_all = "lowercase")] +enum ArtifactKind { + Screenshot, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct CheckInput { + name: String, + status: CheckStatus, + summary: String, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct EvidenceDocument<'a> { + schema_version: u32, + run_id: &'a str, + project_id: &'a str, + project_path: &'a str, + created_at: &'a str, + scenario: &'a str, + outcome: Outcome, + before: &'a Phase, + after: &'a Phase, + source_changes: &'a [String], + checks: &'a [CheckInput], + limitations: &'a [String], +} + +#[derive(Debug, Serialize)] +struct Phase { + summary: String, + observations: Vec, + artifacts: Vec, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct Artifact { + kind: &'static str, + label: String, + path: String, + sha256: String, + bytes: u64, + media_type: &'static str, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RecordResult { + pub schema_version: u32, + pub changed: bool, + pub run_id: String, + pub evidence_path: String, + pub report_path: String, +} + +#[derive(Debug, Error)] +pub enum EvidenceError { + #[error("project directory does not exist or is not a directory: {0}")] + Project(String), + #[error("Flutter project validation failed: {0}")] + Framework(#[from] project::FrameworkError), + #[error("project identity failed: {0}")] + Identity(#[from] project::ProjectIdentityError), + #[error("state directory resolution failed: {0}")] + State(#[from] state::StateError), + #[error("valid Pickforge project receipt required: {0}")] + Receipt(String), + #[error("evidence input exceeds the 1 MiB limit")] + InputTooLarge, + #[error("evidence input is not valid UTF-8")] + InputUtf8, + #[error("invalid evidence input: {0}")] + Input(String), + #[error("unsafe artifact {path}: {reason}")] + Artifact { path: PathBuf, reason: String }, + #[error("could not record evidence: {0}")] + Io(String), +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct Receipt { + schema_version: u32, + project_path: String, + project_id: String, + pack: ReceiptPack, + harnesses: Vec, +} +#[derive(Deserialize)] +struct ReceiptPack { + name: String, + version: u32, +} + +pub fn read_bounded(reader: R) -> Result, EvidenceError> { + let mut bytes = Vec::new(); + reader + .take(MAX_INPUT_BYTES + 1) + .read_to_end(&mut bytes) + .map_err(|e| EvidenceError::Io(e.to_string()))?; + if bytes.len() as u64 > MAX_INPUT_BYTES { + return Err(EvidenceError::InputTooLarge); + } + if std::str::from_utf8(&bytes).is_err() { + return Err(EvidenceError::InputUtf8); + } + Ok(bytes) +} + +pub fn record( + project_dir: &Path, + env: &Environment, + input: &[u8], +) -> Result { + record_at(project_dir, env, input, SystemTime::now()) +} + +pub fn record_at( + project_dir: &Path, + env: &Environment, + input: &[u8], + now: SystemTime, +) -> Result { + if input.len() as u64 > MAX_INPUT_BYTES { + return Err(EvidenceError::InputTooLarge); + } + if std::str::from_utf8(input).is_err() { + return Err(EvidenceError::InputUtf8); + } + let canonical = project::canonical_project_path(project_dir); + if !canonical.is_dir() { + return Err(EvidenceError::Project( + canonical.to_string_lossy().into_owned(), + )); + } + project::detect_flutter(&canonical)?; + let project_id = project::derive_project_id(&canonical)?; + let project_path = canonical + .to_str() + .ok_or(project::ProjectIdentityError::NonUtf8Path)? + .to_owned(); + let root = state::state_root(env)?; + let project_state = state::project_state_dir(&root, &project_id); + validate_receipt( + &project_state.join("project.json"), + &project_path, + &project_id, + )?; + let project_state = fs::canonicalize(&project_state) + .map(project::normalize_windows_canonical_path) + .map_err(|error| EvidenceError::Receipt(error.to_string()))?; + + let mut parsed: EvidenceInput = + serde_json::from_slice(input).map_err(|e| EvidenceError::Input(e.to_string()))?; + validate_input(&parsed)?; + sanitize_input(&mut parsed); + + let instant = OffsetDateTime::from(now) + .replace_nanosecond(0) + .expect("zero nanos valid"); + let created_at = instant + .format(&format_description::well_known::Rfc3339) + .expect("RFC3339 formatting"); + let base_id = instant + .format( + &format_description::parse_borrowed::<2>( + "[year][month][day]-[hour][minute][second]-flutter", + ) + .expect("static format"), + ) + .expect("run id formatting"); + let runs = project_state.join("runs"); + create_private_dirs(&runs).map_err(|e| EvidenceError::Io(e.to_string()))?; + + for collision in 1..=MAX_RUN_ID_ATTEMPTS { + let run_id = if collision == 1 { + base_id.clone() + } else { + format!("{base_id}-{collision}") + }; + let final_dir = runs.join(&run_id); + match fs::symlink_metadata(&final_dir) { + Ok(_) => continue, + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => return Err(EvidenceError::Io(error.to_string())), + } + let temp_dir = runs.join(format!( + ".pickforge-evidence-{run_id}-{}", + std::process::id() + )); + match create_private_dir(&temp_dir) { + Ok(()) => {} + Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue, + Err(e) => return Err(EvidenceError::Io(e.to_string())), + } + let result = build_run( + &temp_dir, + &run_id, + &project_id, + &project_path, + &created_at, + parsed.clone(), + ); + match result { + Ok(()) => match fs::rename(&temp_dir, &final_dir) { + Ok(()) => { + return Ok(RecordResult { + schema_version: EVIDENCE_SCHEMA_VERSION, + changed: true, + run_id, + evidence_path: final_dir + .join("evidence.json") + .to_string_lossy() + .into_owned(), + report_path: final_dir.join("report.md").to_string_lossy().into_owned(), + }) + } + Err(e) + if matches!( + e.kind(), + io::ErrorKind::AlreadyExists | io::ErrorKind::DirectoryNotEmpty + ) || (e.kind() == io::ErrorKind::PermissionDenied + && fs::symlink_metadata(&final_dir).is_ok()) => + { + let _ = fs::remove_dir_all(&temp_dir); + continue; + } + Err(e) => { + let _ = fs::remove_dir_all(&temp_dir); + return Err(EvidenceError::Io(e.to_string())); + } + }, + Err(error) => { + let _ = fs::remove_dir_all(&temp_dir); + return Err(error); + } + } + } + Err(EvidenceError::Io(format!( + "could not allocate an evidence run id after {MAX_RUN_ID_ATTEMPTS} attempts; retry with a later timestamp or inspect {}", + runs.to_string_lossy() + ))) +} + +fn validate_receipt( + path: &Path, + project_path: &str, + project_id: &str, +) -> Result<(), EvidenceError> { + let remediation = + "run `pickforge init --mobile-integration-alpha` for this project and selected harnesses"; + let receipt_error = |reason: String| { + EvidenceError::Receipt(format!( + "{}: {reason}; {remediation}", + path.to_string_lossy() + )) + }; + let metadata = fs::symlink_metadata(path).map_err(|e| receipt_error(e.to_string()))?; + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err(receipt_error("receipt is not a regular file".into())); + } + let mut file = File::open(path).map_err(|e| receipt_error(e.to_string()))?; + let opened = file.metadata().map_err(|e| receipt_error(e.to_string()))?; + if !same_file(&metadata, &opened) { + return Err(receipt_error("receipt changed while opening".into())); + } + let opened_identity = file_identity(&file).map_err(|e| receipt_error(e.to_string()))?; + if opened_identity.link_count > 1 { + return Err(receipt_error("receipt is hardlinked".into())); + } + let bytes = read_bounded(&mut file).map_err(|e| receipt_error(e.to_string()))?; + let path_after = fs::symlink_metadata(path).map_err(|e| receipt_error(e.to_string()))?; + if path_after.file_type().is_symlink() + || !path_after.is_file() + || !same_file(&opened, &path_after) + { + return Err(receipt_error("receipt path changed while reading".into())); + } + let reopened = File::open(path).map_err(|e| receipt_error(e.to_string()))?; + let reopened_identity = file_identity(&reopened).map_err(|e| receipt_error(e.to_string()))?; + if opened_identity != reopened_identity || reopened_identity.link_count > 1 { + return Err(receipt_error("receipt path changed while reading".into())); + } + let receipt: Receipt = + serde_json::from_slice(&bytes).map_err(|e| receipt_error(e.to_string()))?; + if receipt.schema_version != INIT_SCHEMA_VERSION + || receipt.project_path != project_path + || receipt.project_id != project_id + || receipt.pack.name != "pickforge-flutter" + || receipt.pack.version < 1 + || receipt.harnesses.is_empty() + { + return Err(receipt_error( + "receipt does not identify this initialized Flutter project".into(), + )); + } + Ok(()) +} + +fn validate_input(input: &EvidenceInput) -> Result<(), EvidenceError> { + if input.schema_version != EVIDENCE_SCHEMA_VERSION { + return bad(&format!("schemaVersion must be {EVIDENCE_SCHEMA_VERSION}")); + } + bounded_line(&input.scenario, 120, "scenario")?; + validate_phase(&input.before, "before")?; + validate_phase(&input.after, "after")?; + if input.before.artifacts.len() + input.after.artifacts.len() > 16 { + return bad("at most 16 artifacts are allowed"); + } + if input.source_changes.len() > 64 { + return bad("sourceChanges exceeds 64 entries"); + } + for path in &input.source_changes { + validate_relative_path(path)?; + } + if input.checks.len() > 32 { + return bad("checks exceeds 32 entries"); + } + for check in &input.checks { + bounded_line(&check.name, 120, "check name")?; + bounded_text(&check.summary, 1000, "check summary")?; + } + if input.limitations.len() > 32 { + return bad("limitations exceeds 32 entries"); + } + for limitation in &input.limitations { + bounded_text(limitation, 1000, "limitation")?; + } + Ok(()) +} +fn validate_phase(phase: &PhaseInput, name: &str) -> Result<(), EvidenceError> { + bounded_text(&phase.summary, 2000, &format!("{name} summary"))?; + if phase.observations.len() > 32 || phase.artifacts.len() > 8 { + return bad(&format!("{name} exceeds observation or artifact limits")); + } + for item in &phase.observations { + bounded_line(&item.label, 120, "observation label")?; + bounded_text(&item.value, 2000, "observation value")?; + } + for artifact in &phase.artifacts { + bounded_line(&artifact.label, 120, "artifact label")?; + if !artifact.source.is_absolute() { + return bad("artifact source must be absolute"); + } + if artifact + .source + .to_string_lossy() + .chars() + .any(|character| character.is_control() || is_bidi_control(character)) + { + return bad("artifact source contains a control or bidi-control character"); + } + } + Ok(()) +} +fn is_bidi_control(character: char) -> bool { + matches!( + character, + '\u{061c}' + | '\u{200e}' + | '\u{200f}' + | '\u{202a}'..='\u{202e}' + | '\u{2066}'..='\u{2069}' + ) +} +fn bounded_line(value: &str, max: usize, name: &str) -> Result<(), EvidenceError> { + if value.is_empty() + || value.chars().count() > max + || value + .chars() + .any(|character| character.is_control() || is_bidi_control(character)) + { + bad(&format!( + "{name} must be nonempty, single-line, control-free, and at most {max} characters" + )) + } else { + Ok(()) + } +} +fn bounded_text(value: &str, max: usize, name: &str) -> Result<(), EvidenceError> { + if value.chars().count() > max + || value.chars().any(|c| { + is_bidi_control(c) || c == '\0' || (c.is_control() && !matches!(c, '\n' | '\r' | '\t')) + }) + { + bad(&format!( + "{name} contains controls or exceeds {max} characters" + )) + } else { + Ok(()) + } +} +fn validate_relative_path(value: &str) -> Result<(), EvidenceError> { + if value.is_empty() + || value + .chars() + .any(|character| character.is_control() || is_bidi_control(character)) + { + return bad("sourceChanges contains an empty, control-character, or bidi-control path"); + } + let path = Path::new(value); + if path.is_absolute() + || path + .components() + .any(|c| !matches!(c, Component::Normal(_))) + || value.contains('\\') + { + return bad("sourceChanges must contain normalized project-relative paths"); + } + Ok(()) +} +fn bad(message: &str) -> Result { + Err(EvidenceError::Input(message.into())) +} + +fn sanitize_input(input: &mut EvidenceInput) { + input.scenario = redact_secrets(&input.scenario); + sanitize_phase(&mut input.before); + sanitize_phase(&mut input.after); + for path in &mut input.source_changes { + *path = redact_secrets(path); + } + input.source_changes.sort(); + input.source_changes.dedup(); + for check in &mut input.checks { + check.name = redact_secrets(&check.name); + check.summary = redact_secrets(&check.summary); + } + for value in &mut input.limitations { + *value = redact_secrets(value); + } +} +fn sanitize_phase(phase: &mut PhaseInput) { + phase.summary = redact_secrets(&phase.summary); + for item in &mut phase.observations { + item.label = redact_secrets(&item.label); + item.value = redact_secrets(&item.value); + } + for item in &mut phase.artifacts { + item.label = redact_secrets(&item.label); + } +} + +fn redact_secrets(value: &str) -> String { + static PATTERNS: OnceLock> = OnceLock::new(); + let patterns = PATTERNS.get_or_init(|| vec![ + Regex::new(r#"(?i)(\bauthorization\b\s*[:=]\s*)(?:\"[^\"]*\"|'[^']*'|(?:bearer\s+)?[^\s,;\"']+)"#).unwrap(), + Regex::new(r#"(?i)(\b(?:set-cookie|cookie)\b\s*[:=]\s*)(?:\"[^\"]*\"|'[^']*'|[^\r\n]+)"#).unwrap(), + Regex::new(r#"(?i)(\b(?:api[_-]?key|token|secret|password|passwd)\b\s*[=:]\s*)(?:\"[^\"]*\"|'[^']*'|[^\s,;\"']+)"#).unwrap(), + Regex::new(r#"(?i)(\"(?:api[_-]?key|token|secret|password|passwd|authorization|cookie|set-cookie)\"\s*:\s*\")[^\"]*(\")"#).unwrap(), + Regex::new(r"\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b").unwrap(), + Regex::new(r"\b(?:gh[pousr]_[A-Za-z0-9]{8,}|sk-[A-Za-z0-9_-]{8,}|AKIA[A-Z0-9]{16})\b").unwrap(), + ]); + let mut output = value.to_owned(); + for (index, pattern) in patterns.iter().enumerate() { + output = if index == 3 { + pattern.replace_all(&output, "$1[REDACTED]$2").into_owned() + } else if index < 3 { + pattern.replace_all(&output, "$1[REDACTED]").into_owned() + } else { + pattern.replace_all(&output, "[REDACTED]").into_owned() + }; + } + output +} + +fn build_run( + temp: &Path, + run_id: &str, + project_id: &str, + project_path: &str, + created_at: &str, + input: EvidenceInput, +) -> Result<(), EvidenceError> { + let artifacts_dir = temp.join("artifacts"); + create_private_dir(&artifacts_dir).map_err(|e| EvidenceError::Io(e.to_string()))?; + let mut total = 0u64; + let mut known = BTreeMap::::new(); + let before = materialize_phase( + input.before, + "before", + &artifacts_dir, + &mut total, + &mut known, + )?; + let after = materialize_phase(input.after, "after", &artifacts_dir, &mut total, &mut known)?; + let doc = EvidenceDocument { + schema_version: EVIDENCE_SCHEMA_VERSION, + run_id, + project_id, + project_path, + created_at, + scenario: &input.scenario, + outcome: input.outcome, + before: &before, + after: &after, + source_changes: &input.source_changes, + checks: &input.checks, + limitations: &input.limitations, + }; + let mut json = serde_json::to_vec_pretty(&doc).expect("typed evidence serializes"); + json.push(b'\n'); + write_private(&temp.join("evidence.json"), &json)?; + write_private(&temp.join("report.md"), render_report(&doc).as_bytes())?; + Ok(()) +} +fn materialize_phase( + input: PhaseInput, + phase: &str, + dir: &Path, + total: &mut u64, + known: &mut BTreeMap, +) -> Result { + let mut artifacts = Vec::new(); + for source in input.artifacts { + let ArtifactKind::Screenshot = source.kind; + let (bytes, media, ext, hash) = read_image(&source.source)?; + let artifact = if let Some(existing) = known.get(&hash) { + existing.clone() + } else { + *total = total + .checked_add(bytes.len() as u64) + .ok_or_else(|| EvidenceError::Input("artifact byte total overflow".into()))?; + if *total > MAX_TOTAL_IMAGE_BYTES { + return Err(EvidenceError::Artifact { + path: source.source, + reason: "copied artifacts exceed 64 MiB total".into(), + }); + } + let slug = slug(&source.label); + let relative = format!("artifacts/{phase}-{slug}-{}.{ext}", &hash[..12]); + let target = dir.parent().unwrap().join(&relative); + if target.exists() { + let existing = fs::read(&target).map_err(|e| EvidenceError::Io(e.to_string()))?; + if existing != bytes { + return Err(EvidenceError::Artifact { + path: target, + reason: "content-addressed filename collision".into(), + }); + } + } else { + write_private(&target, &bytes)?; + } + let artifact = Artifact { + kind: "screenshot", + label: source.label.clone(), + path: relative, + sha256: hash.clone(), + bytes: bytes.len() as u64, + media_type: media, + }; + known.insert(hash.clone(), artifact.clone()); + artifact + }; + let mut artifact = artifact; + artifact.label = source.label; + artifacts.push(artifact); + } + Ok(Phase { + summary: input.summary, + observations: input.observations, + artifacts, + }) +} +fn read_image(path: &Path) -> Result<(Vec, &'static str, &'static str, String), EvidenceError> { + let fail = |reason: &str| EvidenceError::Artifact { + path: path.into(), + reason: reason.into(), + }; + let before = fs::symlink_metadata(path).map_err(|e| fail(&e.to_string()))?; + if before.file_type().is_symlink() || !before.is_file() { + return Err(fail("source must be a non-symlink regular file")); + } + if before.len() > MAX_IMAGE_BYTES { + return Err(fail("image exceeds 8 MiB")); + } + let mut file = File::open(path).map_err(|e| fail(&e.to_string()))?; + let opened = file.metadata().map_err(|e| fail(&e.to_string()))?; + if !same_file(&before, &opened) { + return Err(fail("source changed while opening")); + } + let opened_identity = file_identity(&file).map_err(|e| fail(&e.to_string()))?; + if opened_identity.link_count > 1 { + return Err(fail("hardlinked source is refused")); + } + let mut bytes = Vec::with_capacity((opened.len().min(MAX_IMAGE_BYTES + 1)) as usize); + Read::by_ref(&mut file) + .take(MAX_IMAGE_BYTES + 1) + .read_to_end(&mut bytes) + .map_err(|e| fail(&e.to_string()))?; + if bytes.len() as u64 > MAX_IMAGE_BYTES { + return Err(fail("image exceeds 8 MiB")); + } + let after = file.metadata().map_err(|e| fail(&e.to_string()))?; + if !same_file(&opened, &after) || after.len() != bytes.len() as u64 { + return Err(fail("source changed while copying")); + } + let path_after = fs::symlink_metadata(path).map_err(|e| fail(&e.to_string()))?; + if path_after.file_type().is_symlink() + || !path_after.is_file() + || !same_file(&opened, &path_after) + { + return Err(fail("source path was replaced while copying")); + } + let reopened = File::open(path).map_err(|e| fail(&e.to_string()))?; + let reopened_identity = file_identity(&reopened).map_err(|e| fail(&e.to_string()))?; + if opened_identity != reopened_identity { + return Err(fail("source path was replaced while copying")); + } + if reopened_identity.link_count > 1 { + return Err(fail("hardlinked source is refused")); + } + let (media, ext) = + image_type(&bytes).ok_or_else(|| fail("source is not a PNG, JPEG, or WebP image"))?; + let hash = format!("{:x}", Sha256::digest(&bytes)); + Ok((bytes, media, ext, hash)) +} +#[cfg(unix)] +fn same_file(a: &fs::Metadata, b: &fs::Metadata) -> bool { + use std::os::unix::fs::MetadataExt; + a.dev() == b.dev() + && a.ino() == b.ino() + && a.len() == b.len() + && a.mtime() == b.mtime() + && a.mtime_nsec() == b.mtime_nsec() +} +#[cfg(windows)] +fn same_file(a: &fs::Metadata, b: &fs::Metadata) -> bool { + use std::os::windows::fs::MetadataExt; + a.file_attributes() == b.file_attributes() + && a.creation_time() == b.creation_time() + && a.last_write_time() == b.last_write_time() + && a.file_size() == b.file_size() +} +#[cfg(not(any(unix, windows)))] +fn same_file(a: &fs::Metadata, b: &fs::Metadata) -> bool { + a.len() == b.len() && a.modified().ok() == b.modified().ok() +} +#[derive(Debug, PartialEq, Eq)] +struct FileIdentity { + volume: u64, + index: u64, + link_count: u64, +} + +#[cfg(unix)] +fn file_identity(file: &File) -> io::Result { + use std::os::unix::fs::MetadataExt; + let metadata = file.metadata()?; + Ok(FileIdentity { + volume: metadata.dev(), + index: metadata.ino(), + link_count: metadata.nlink(), + }) +} + +#[cfg(windows)] +fn file_identity(file: &File) -> io::Result { + use std::mem::MaybeUninit; + use std::os::windows::io::AsRawHandle; + use windows_sys::Win32::Storage::FileSystem::{ + GetFileInformationByHandle, BY_HANDLE_FILE_INFORMATION, + }; + + let mut information = MaybeUninit::::uninit(); + // SAFETY: the handle is owned by `file`, and Windows initializes `information` on success. + let succeeded = + unsafe { GetFileInformationByHandle(file.as_raw_handle(), information.as_mut_ptr()) }; + if succeeded == 0 { + return Err(io::Error::last_os_error()); + } + // SAFETY: GetFileInformationByHandle succeeded and initialized the structure. + let information = unsafe { information.assume_init() }; + Ok(FileIdentity { + volume: information.dwVolumeSerialNumber as u64, + index: ((information.nFileIndexHigh as u64) << 32) | information.nFileIndexLow as u64, + link_count: information.nNumberOfLinks as u64, + }) +} + +#[cfg(not(any(unix, windows)))] +fn file_identity(file: &File) -> io::Result { + let metadata = file.metadata()?; + Ok(FileIdentity { + volume: 0, + index: 0, + link_count: 1, + }) +} +fn image_type(bytes: &[u8]) -> Option<(&'static str, &'static str)> { + if bytes.starts_with(b"\x89PNG\r\n\x1a\n") { + Some(("image/png", "png")) + } else if bytes.starts_with(b"\xff\xd8\xff") { + Some(("image/jpeg", "jpg")) + } else if bytes.len() >= 12 && &bytes[..4] == b"RIFF" && &bytes[8..12] == b"WEBP" { + Some(("image/webp", "webp")) + } else { + None + } +} +fn slug(value: &str) -> String { + let mut out = String::new(); + for c in value.to_ascii_lowercase().chars() { + if c.is_ascii_alphanumeric() { + out.push(c); + } else if !out.ends_with('-') { + out.push('-'); + } + } + let out = out.trim_matches('-'); + if out.is_empty() { + "artifact".into() + } else { + out.chars().take(40).collect() + } +} +fn normalize_markdown(value: &str) -> String { + let flat = value + .replace("\r\n", " ") + .replace(['\r', '\n', '\t'], " ") + .split_whitespace() + .collect::>() + .join(" "); + let mut escaped = String::new(); + for character in flat.chars() { + if matches!( + character, + '\\' | '#' | '*' | '_' | '`' | '[' | ']' | '<' | '>' + ) { + escaped.push('\\'); + } + escaped.push(character); + } + escaped +} +fn render_report(doc: &EvidenceDocument<'_>) -> String { + let outcome = match doc.outcome { + Outcome::Passed => "passed", + Outcome::Failed => "failed", + Outcome::Inconclusive => "inconclusive", + }; + let mut out = format!( + "# Flutter evidence: {}\n\n**Outcome:** {outcome}\n\n## Before\n\n{}\n", + normalize_markdown(doc.scenario), + normalize_markdown(&doc.before.summary) + ); + render_observations(&mut out, &doc.before.observations); + out.push_str("\n## After\n\n"); + out.push_str(&normalize_markdown(&doc.after.summary)); + out.push('\n'); + render_observations(&mut out, &doc.after.observations); + out.push_str("\n## Source changes\n\n"); + render_list( + &mut out, + doc.source_changes.iter().map(|s| normalize_markdown(s)), + ); + out.push_str("\n## Checks\n\n"); + if doc.checks.is_empty() { + out.push_str("- None\n"); + } else { + for c in doc.checks { + out.push_str(&format!( + "- **{}** ({:?}): {}\n", + normalize_markdown(&c.name), + c.status, + normalize_markdown(&c.summary) + )); + } + } + out.push_str("\n## Artifacts\n\n"); + let all = doc + .before + .artifacts + .iter() + .chain(&doc.after.artifacts) + .collect::>(); + if all.is_empty() { + out.push_str("- None\n"); + } else { + for a in all { + out.push_str(&format!( + "- [{}]({}) ({} bytes, `{}`)\n", + normalize_markdown(&a.label), + normalize_markdown(&a.path), + a.bytes, + a.sha256 + )); + } + } + out.push_str("\n## Limitations\n\n"); + render_list( + &mut out, + doc.limitations.iter().map(|s| normalize_markdown(s)), + ); + out +} +fn render_observations(out: &mut String, items: &[Observation]) { + if !items.is_empty() { + out.push('\n'); + for item in items { + out.push_str(&format!( + "- **{}:** {}\n", + normalize_markdown(&item.label), + normalize_markdown(&item.value) + )); + } + } +} +fn render_list(out: &mut String, items: impl Iterator) { + let items = items.collect::>(); + if items.is_empty() { + out.push_str("- None\n"); + } else { + for item in items { + out.push_str(&format!("- {item}\n")); + } + } +} +fn create_private_dirs(path: &Path) -> io::Result<()> { + match fs::symlink_metadata(path) { + Ok(metadata) if metadata.file_type().is_symlink() => { + return Err(io::Error::other("final state directory is a symbolic link")); + } + Ok(metadata) if metadata.is_dir() => return Ok(()), + Ok(_) => return Err(io::Error::other("state parent is not a directory")), + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => return Err(error), + } + create_private_dirs(path.parent().ok_or_else(|| io::Error::other("no parent"))?)?; + match create_private_dir(path) { + Ok(()) => Ok(()), + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => { + let metadata = fs::symlink_metadata(path)?; + if metadata.is_dir() && !metadata.file_type().is_symlink() { + Ok(()) + } else { + Err(io::Error::other( + "concurrently created state path is not a real directory", + )) + } + } + Err(error) => Err(error), + } +} +fn create_private_dir(path: &Path) -> io::Result<()> { + #[cfg(unix)] + { + use std::os::unix::fs::DirBuilderExt; + let mut builder = fs::DirBuilder::new(); + builder.mode(0o700).create(path) + } + #[cfg(not(unix))] + fs::DirBuilder::new().create(path) +} +fn write_private(path: &Path, bytes: &[u8]) -> Result<(), EvidenceError> { + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let mut file = options + .open(path) + .map_err(|e| EvidenceError::Io(e.to_string()))?; + file.write_all(bytes) + .and_then(|_| file.sync_all()) + .map_err(|e| EvidenceError::Io(e.to_string())) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn redacts_supported_secret_shapes() { + let raw = "api_key=abc123 token: \"quoted\" password='single' JSON {\"token\":\"xyz\"} Authorization: Bearer hello Cookie: sid=secret eyJabc.def.ghi ghp_123456789 sk-123456789 AKIA1234567890123456"; + let clean = redact_secrets(raw); + for secret in [ + "abc123", + "quoted", + "single", + "xyz", + "hello", + "sid=secret", + "eyJabc.def.ghi", + "ghp_123456789", + "sk-123456789", + "AKIA1234567890123456", + ] { + assert!(!clean.contains(secret), "{clean}"); + } + } + + #[test] + fn quoted_authorization_and_cookie_keep_safe_trailing_text() { + let clean = redact_secrets( + "authorization: \"planted-auth-secret\" safe-yaml cookie = 'planted-cookie-secret' safe-toml", + ); + assert!(!clean.contains("planted-auth-secret"), "{clean}"); + assert!(!clean.contains("planted-cookie-secret"), "{clean}"); + assert!(clean.contains("safe-yaml"), "{clean}"); + assert!(clean.contains("safe-toml"), "{clean}"); + + let header = redact_secrets("Cookie: first=secret; second=also-secret"); + assert!(!header.contains("secret"), "{header}"); + + let json = redact_secrets(r#"{"passwd":"json-secret"}"#); + assert!(!json.contains("json-secret"), "{json}"); + } + + #[test] + fn redaction_marker_cannot_become_a_markdown_link() { + assert_eq!( + normalize_markdown("[REDACTED](https://evil.example)"), + "\\[REDACTED\\](https://evil.example)" + ); + } + + #[test] + fn recognizes_image_magic() { + assert_eq!( + image_type(b"\x89PNG\r\n\x1a\nrest"), + Some(("image/png", "png")) + ); + assert_eq!( + image_type(b"\xff\xd8\xffwithout-eoi"), + Some(("image/jpeg", "jpg")) + ); + assert_eq!(image_type(b"suffix.png"), None); + } + + #[test] + fn concurrent_private_directory_creation_is_safe() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("one/two/three"); + std::thread::scope(|scope| { + for _ in 0..16 { + let path = &path; + scope.spawn(move || create_private_dirs(path).unwrap()); + } + }); + let metadata = fs::symlink_metadata(path).unwrap(); + assert!(metadata.is_dir()); + assert!(!metadata.file_type().is_symlink()); + } +} diff --git a/crates/pickforge-cli/src/init.rs b/crates/pickforge-cli/src/init.rs index 01d4c05..b98c150 100644 --- a/crates/pickforge-cli/src/init.rs +++ b/crates/pickforge-cli/src/init.rs @@ -189,16 +189,19 @@ fn recoverable_state_artifacts( entry.path().display() ) })?; - if !metadata.is_file() || metadata.len() > MAX_STATE_ARTIFACT_BYTES { - return Ok(false); - } let name = entry.file_name(); let Some(name) = name.to_str() else { return Ok(false); }; - if name.starts_with(".pickforge-tmp-") { + if name.starts_with(".pickforge-tmp-") + && metadata.is_file() + && metadata.len() <= MAX_STATE_ARTIFACT_BYTES + { continue; } + if !metadata.is_file() || metadata.len() > MAX_STATE_ARTIFACT_BYTES { + return Ok(false); + } if name.starts_with("project.json.pickforge-backup-") { let (_, bytes) = transaction::inspect_file(entry.path(), true).map_err(|error| { format!( diff --git a/crates/pickforge-cli/src/lib.rs b/crates/pickforge-cli/src/lib.rs index 7236eff..05dc6a1 100644 --- a/crates/pickforge-cli/src/lib.rs +++ b/crates/pickforge-cli/src/lib.rs @@ -6,6 +6,7 @@ pub mod adapters; pub mod doctor; pub mod env; +pub mod evidence; pub mod init; pub mod project; pub mod render; @@ -16,5 +17,6 @@ pub mod transaction; pub use doctor::diagnose; pub use env::Environment; +pub use evidence::{record, EvidenceError, RecordResult}; pub use init::{apply_init, plan_init, ApplyReport, InitPlan, InitPlanReport, InitRequest}; pub use report::{Check, CheckStatus, DoctorReport, ProjectInfo, SCHEMA_VERSION}; diff --git a/crates/pickforge-cli/src/main.rs b/crates/pickforge-cli/src/main.rs index cc3405b..0a7fe7f 100644 --- a/crates/pickforge-cli/src/main.rs +++ b/crates/pickforge-cli/src/main.rs @@ -1,9 +1,12 @@ +use std::fs::File; +use std::io; use std::path::PathBuf; use std::process::ExitCode; use std::time::{SystemTime, UNIX_EPOCH}; use clap::{Parser, Subcommand}; use pickforge_cli::adapters::{Harness, IntegrationPack}; +use pickforge_cli::evidence::EVIDENCE_SCHEMA_VERSION; use pickforge_cli::init::{ApplyState, InitRequest}; use pickforge_cli::{apply_init, diagnose, plan_init, render, Environment}; use serde::Serialize; @@ -29,6 +32,11 @@ enum Command { #[arg(long)] json: bool, }, + /// Record one completed Flutter evidence run. + Evidence { + #[command(subcommand)] + command: EvidenceCommand, + }, /// Plan or apply experimental harness integration. Init { #[arg(long, value_name = "PATH")] @@ -44,6 +52,19 @@ enum Command { }, } +#[derive(Subcommand)] +enum EvidenceCommand { + /// Validate and record a complete evidence document. + Record { + #[arg(long, value_name = "PATH")] + project_dir: Option, + #[arg(long, value_name = "PATH", default_value = "-")] + input: String, + #[arg(long)] + json: bool, + }, +} + #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct InitOutput<'a> { @@ -87,6 +108,67 @@ fn main() -> ExitCode { ExitCode::FAILURE } } + Command::Evidence { command } => match command { + EvidenceCommand::Record { + project_dir, + input, + json, + } => { + let project_dir = project_dir + .or_else(|| std::env::current_dir().ok()) + .unwrap_or_else(|| PathBuf::from(".")); + let bytes = if input == "-" { + pickforge_cli::evidence::read_bounded(io::stdin().lock()) + } else { + File::open(&input) + .map_err(|error| { + pickforge_cli::EvidenceError::Io(format!( + "could not read evidence input file {input}: {error}; verify or fix `--input`" + )) + }) + .and_then(|file| { + pickforge_cli::evidence::read_bounded(file).map_err(|error| { + pickforge_cli::EvidenceError::Io(format!( + "could not read evidence input file {input}: {error}; verify or fix `--input`" + )) + }) + }) + }; + let outcome = bytes.and_then(|bytes| { + pickforge_cli::record(&project_dir, &Environment::from_process(), &bytes) + }); + match outcome { + Ok(result) => { + if json { + print!("{}", json_line(&result)); + } else { + println!("recorded Flutter evidence run {}", result.run_id); + println!("evidence: {}", render::terminal_safe(&result.evidence_path)); + println!("report: {}", render::terminal_safe(&result.report_path)); + } + ExitCode::SUCCESS + } + Err(error) => { + let message = error.to_string(); + if json { + print!( + "{}", + json_line(&ErrorOutput { + schema_version: EVIDENCE_SCHEMA_VERSION, + error: &message + }) + ); + } else { + eprintln!( + "pickforge evidence record failed: {}", + render::terminal_safe(&message) + ); + } + ExitCode::FAILURE + } + } + } + }, Command::Init { project_dir, harness, diff --git a/crates/pickforge-cli/src/render.rs b/crates/pickforge-cli/src/render.rs index 6c236f1..859e1eb 100644 --- a/crates/pickforge-cli/src/render.rs +++ b/crates/pickforge-cli/src/render.rs @@ -13,7 +13,16 @@ fn label(status: CheckStatus) -> &'static str { pub fn terminal_safe(value: &str) -> String { let mut escaped = String::with_capacity(value.len()); for character in value.chars() { - if character.is_control() { + if character.is_control() + || matches!( + character, + '\u{061c}' + | '\u{200e}' + | '\u{200f}' + | '\u{202a}'..='\u{202e}' + | '\u{2066}'..='\u{2069}' + ) + { escaped.extend(character.escape_default()); } else { escaped.push(character); @@ -159,3 +168,14 @@ pub fn render_text(report: &DoctorReport) -> String { )); out } + +#[cfg(test)] +mod tests { + use super::terminal_safe; + + #[test] + fn terminal_output_escapes_bidi_controls() { + let safe = terminal_safe("before\u{202e}spoof\u{2066}after"); + assert_eq!(safe, "before\\u{202e}spoof\\u{2066}after"); + } +} diff --git a/crates/pickforge-cli/tests/cli.rs b/crates/pickforge-cli/tests/cli.rs index c2acaca..0a8bbe8 100644 --- a/crates/pickforge-cli/tests/cli.rs +++ b/crates/pickforge-cli/tests/cli.rs @@ -394,3 +394,119 @@ fn init_precondition_failure_exits_one_without_writing() { assert!(!stdout.contains("\\u{ef}"), "{stdout:?}"); assert!(!temp.path().join("state").exists()); } + +#[test] +fn evidence_record_supports_stdin_path_human_json_and_errors() { + let temp = TempDir::new().unwrap(); + let project_dir = flutter_project(temp.path()); + git(&project_dir, &["init", "--quiet"]); + git(&project_dir, &["add", "pubspec.yaml"]); + git( + &project_dir, + &[ + "-c", + "user.name=Pickforge Test", + "-c", + "user.email=test@invalid.example", + "commit", + "--quiet", + "-m", + "fixture", + ], + ); + let status_before = git(&project_dir, &["status", "--porcelain=v1"]); + let project_before = snapshot_without_git(&project_dir); + let input = serde_json::json!({"schemaVersion":1,"scenario":"Smoke","outcome":"passed","before":{"summary":"Before","observations":[],"artifacts":[]},"after":{"summary":"After","observations":[],"artifacts":[]},"sourceChanges":[],"checks":[],"limitations":[]}).to_string(); + let missing_receipt = pickforge(temp.path(), &[]) + .args(["evidence", "record", "--project-dir"]) + .arg(&project_dir) + .write_stdin(input.as_bytes()) + .assert() + .code(1); + let stderr = String::from_utf8_lossy(&missing_receipt.get_output().stderr); + let expected_receipt = temp.path().join("state").join("projects"); + assert!( + stderr.contains(&expected_receipt.to_string_lossy().into_owned()) + && stderr.contains("project.json"), + "{stderr}" + ); + assert!( + stderr.contains("pickforge init --mobile-integration-alpha"), + "{stderr}" + ); + + pickforge(temp.path(), &["dart"]) + .args([ + "init", + "--mobile-integration-alpha", + "--harness", + "codex", + "--project-dir", + ]) + .arg(&project_dir) + .assert() + .success(); + let missing_input = temp.path().join("missing-input.json"); + let input_error = pickforge(temp.path(), &[]) + .args(["evidence", "record", "--input"]) + .arg(&missing_input) + .arg("--project-dir") + .arg(&project_dir) + .assert() + .code(1); + let stderr = String::from_utf8_lossy(&input_error.get_output().stderr); + assert!( + stderr.contains(&missing_input.to_string_lossy().into_owned()), + "{stderr}" + ); + assert!(stderr.contains("verify or fix `--input`"), "{stderr}"); + assert!(!stderr.contains("pickforge init"), "{stderr}"); + + let human = pickforge(temp.path(), &[]) + .args(["evidence", "record", "--project-dir"]) + .arg(&project_dir) + .write_stdin(input.as_bytes()) + .assert() + .success(); + let stdout = String::from_utf8_lossy(&human.get_output().stdout); + assert!(stdout.contains("recorded Flutter evidence run"), "{stdout}"); + assert!( + stdout.contains("evidence:") && stdout.contains("report:"), + "{stdout}" + ); + + let input_path = temp.path().join("input.json"); + std::fs::write(&input_path, input).unwrap(); + let json = pickforge(temp.path(), &[]) + .args(["evidence", "record", "--json", "--input"]) + .arg(&input_path) + .arg("--project-dir") + .arg(&project_dir) + .assert() + .success(); + let value: serde_json::Value = serde_json::from_slice(&json.get_output().stdout).unwrap(); + assert_eq!(value["schemaVersion"], 1); + assert_eq!(value["changed"], true); + assert!(value["evidencePath"] + .as_str() + .unwrap() + .ends_with("evidence.json")); + + let error = pickforge(temp.path(), &[]) + .args(["evidence", "record", "--json", "--project-dir"]) + .arg(&project_dir) + .write_stdin("{}") + .assert() + .code(1); + let value: serde_json::Value = serde_json::from_slice(&error.get_output().stdout).unwrap(); + assert_eq!(value["schemaVersion"], 1); + assert!(value["error"] + .as_str() + .unwrap() + .contains("invalid evidence input")); + assert_eq!( + git(&project_dir, &["status", "--porcelain=v1"]), + status_before + ); + assert_eq!(snapshot_without_git(&project_dir), project_before); +} diff --git a/crates/pickforge-cli/tests/evidence.rs b/crates/pickforge-cli/tests/evidence.rs new file mode 100644 index 0000000..a4d24c8 --- /dev/null +++ b/crates/pickforge-cli/tests/evidence.rs @@ -0,0 +1,363 @@ +use std::path::{Path, PathBuf}; +use std::time::{Duration, UNIX_EPOCH}; + +use pickforge_cli::adapters::{Harness, IntegrationPack}; +use pickforge_cli::evidence::{record_at, EvidenceError, EVIDENCE_SCHEMA_VERSION}; +use pickforge_cli::project::{canonical_project_path, derive_project_id}; +use pickforge_cli::{apply_init, plan_init, Environment, InitRequest}; +use tempfile::TempDir; + +const PUBSPEC: &str = "name: app\ndependencies:\n flutter:\n sdk: flutter\n"; +const PNG: &[u8] = b"\x89PNG\r\n\x1a\nfixture"; + +fn fixture() -> (TempDir, PathBuf, Environment, PathBuf) { + let temp = TempDir::new().unwrap(); + let project = temp.path().join("app"); + std::fs::create_dir(&project).unwrap(); + std::fs::write(project.join("pubspec.yaml"), PUBSPEC).unwrap(); + let state = temp.path().join("state"); + let env = Environment::empty() + .with_home_dir(temp.path().join("home")) + .with_var("PICKFORGE_HOME", &state); + let mut request = InitRequest::new(&project); + request.pack = IntegrationPack::flutter(); + request.harnesses = vec![Harness::Codex]; + let bin = temp.path().join("bin"); + std::fs::create_dir(&bin).unwrap(); + let dart = if cfg!(windows) { + bin.join("dart.EXE") + } else { + bin.join("dart") + }; + std::fs::write(&dart, "fixture").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&dart, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + let env = env.with_var("PATH", &bin).with_var("PATHEXT", ".EXE"); + let plan = plan_init(&request, &env).unwrap(); + assert!(apply_init(&plan, "fixture").changed); + (temp, project, env, PathBuf::from(plan.report.state_dir)) +} + +fn envelope(before: &[(&str, &Path)], after: &[(&str, &Path)]) -> Vec { + let artifacts = |items: &[(&str, &Path)]| { + items.iter().map(|(label, path)| serde_json::json!({"kind":"screenshot","label":label,"source":path})).collect::>() + }; + serde_json::to_vec(&serde_json::json!({ + "schemaVersion":1, + "scenario":"Counter increments", + "outcome":"passed", + "before":{"summary":"Counter was zero.","observations":[{"label":"Counter","value":"0"}],"artifacts":artifacts(before)}, + "after":{"summary":"Counter is one.","observations":[{"label":"Counter","value":"1"}],"artifacts":artifacts(after)}, + "sourceChanges":["lib/main.dart","lib/main.dart"], + "checks":[{"name":"flutter test","status":"passed","summary":"Passed."}], + "limitations":["Pixel review was manual."] + })).unwrap() +} + +#[test] +fn golden_documents_are_byte_exact_and_aliases_dedupe() { + let (_temp, project, env, state) = fixture(); + let first = state.parent().unwrap().join("first.any"); + let alias = state.parent().unwrap().join("alias.jpg"); + std::fs::write(&first, PNG).unwrap(); + std::fs::write(&alias, PNG).unwrap(); + let mut input: serde_json::Value = serde_json::from_slice(&envelope( + &[("Initial *shot*", &first)], + &[("Final [shot]", &alias)], + )) + .unwrap(); + input["scenario"] = "Counter *increments*".into(); + input["before"]["summary"] = "Counter was **zero**.\nSecond line.".into(); + input["before"]["observations"][0]["label"] = "Counter_[raw]".into(); + input["before"]["observations"][0]["value"] = "0\nstill zero".into(); + let result = record_at( + &project, + &env, + &serde_json::to_vec(&input).unwrap(), + UNIX_EPOCH + Duration::from_secs(1_704_067_200), + ) + .unwrap(); + assert_eq!(result.run_id, "20240101-000000-flutter"); + let json = std::fs::read_to_string(&result.evidence_path).unwrap(); + let value: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert_eq!(value["schemaVersion"], EVIDENCE_SCHEMA_VERSION); + assert_eq!(value["createdAt"], "2024-01-01T00:00:00Z"); + assert_eq!(value["sourceChanges"], serde_json::json!(["lib/main.dart"])); + assert_eq!( + value["before"]["artifacts"][0]["path"], + value["after"]["artifacts"][0]["path"] + ); + let canonical_project = canonical_project_path(&project); + let project_id = derive_project_id(&canonical_project).unwrap(); + let artifact = serde_json::json!({ + "kind":"screenshot","label":"Initial *shot*", + "path":"artifacts/before-initial-shot-bd54b02fae14.png", + "sha256":"bd54b02fae14b6b9ed73887ded339b8ef846fbcba0d4e5f9d95470ac23ade242", + "bytes":15,"mediaType":"image/png" + }); + let mut final_artifact = artifact.clone(); + final_artifact["label"] = "Final [shot]".into(); + let expected = serde_json::json!({ + "schemaVersion":EVIDENCE_SCHEMA_VERSION,"runId":"20240101-000000-flutter","projectId":project_id, + "projectPath":canonical_project.to_str().unwrap(),"createdAt":"2024-01-01T00:00:00Z", + "scenario":"Counter *increments*","outcome":"passed", + "before":{"summary":"Counter was **zero**.\nSecond line.","observations":[{"label":"Counter_[raw]","value":"0\nstill zero"}],"artifacts":[artifact]}, + "after":{"summary":"Counter is one.","observations":[{"label":"Counter","value":"1"}],"artifacts":[final_artifact]}, + "sourceChanges":["lib/main.dart"],"checks":[{"name":"flutter test","status":"passed","summary":"Passed."}], + "limitations":["Pixel review was manual."] + }); + assert_eq!( + json, + format!("{}\n", serde_json::to_string_pretty(&expected).unwrap()) + ); + let artifact_dir = Path::new(&result.evidence_path) + .parent() + .unwrap() + .join("artifacts"); + assert_eq!(std::fs::read_dir(artifact_dir).unwrap().count(), 1); + let report = std::fs::read_to_string(&result.report_path).unwrap(); + assert_eq!(report, "# Flutter evidence: Counter \\*increments\\*\n\n**Outcome:** passed\n\n## Before\n\nCounter was \\*\\*zero\\*\\*. Second line.\n\n- **Counter\\_\\[raw\\]:** 0 still zero\n\n## After\n\nCounter is one.\n\n- **Counter:** 1\n\n## Source changes\n\n- lib/main.dart\n\n## Checks\n\n- **flutter test** (Passed): Passed.\n\n## Artifacts\n\n- [Initial \\*shot\\*](artifacts/before-initial-shot-bd54b02fae14.png) (15 bytes, `bd54b02fae14b6b9ed73887ded339b8ef846fbcba0d4e5f9d95470ac23ade242`)\n- [Final \\[shot\\]](artifacts/before-initial-shot-bd54b02fae14.png) (15 bytes, `bd54b02fae14b6b9ed73887ded339b8ef846fbcba0d4e5f9d95470ac23ade242`)\n\n## Limitations\n\n- Pixel review was manual.\n"); +} + +#[test] +fn collision_suffixing_and_atomic_failure_cleanup() { + let (_temp, project, env, state) = fixture(); + let image = state.parent().unwrap().join("image"); + std::fs::write(&image, PNG).unwrap(); + let now = UNIX_EPOCH + Duration::from_secs(1_704_067_200); + let first = record_at(&project, &env, &envelope(&[], &[("Image", &image)]), now).unwrap(); + let second = record_at(&project, &env, &envelope(&[], &[]), now).unwrap(); + assert_eq!(first.run_id, "20240101-000000-flutter"); + assert_eq!(second.run_id, "20240101-000000-flutter-2"); + let bad = state.parent().unwrap().join("bad"); + std::fs::write(&bad, "not image").unwrap(); + assert!(record_at( + &project, + &env, + &envelope(&[("Good", &image)], &[("Bad", &bad)]), + now + ) + .is_err()); + let names = std::fs::read_dir(state.join("runs")) + .unwrap() + .map(|e| e.unwrap().file_name().to_string_lossy().into_owned()) + .collect::>(); + assert_eq!(names.len(), 2, "{names:?}"); + assert!(names + .iter() + .all(|name| !name.starts_with(".pickforge-evidence-"))); +} + +#[test] +fn rejects_receipt_schema_paths_controls_unknown_fields_and_limits() { + let (_temp, project, env, state) = fixture(); + let valid = envelope(&[], &[]); + let cases = [ + serde_json::json!({"schemaVersion":2,"scenario":"x","outcome":"passed","before":{"summary":"","observations":[],"artifacts":[]},"after":{"summary":"","observations":[],"artifacts":[]}}), + serde_json::json!({"schemaVersion":1,"scenario":"x\n# fake","outcome":"passed","before":{"summary":"","observations":[],"artifacts":[]},"after":{"summary":"","observations":[],"artifacts":[]}}), + serde_json::json!({"schemaVersion":1,"scenario":"x","outcome":"passed","before":{"summary":"","observations":[],"artifacts":[]},"after":{"summary":"","observations":[],"artifacts":[]},"sourceChanges":["../secret"]}), + serde_json::json!({"schemaVersion":1,"scenario":"x","outcome":"passed","before":{"summary":"safe\u{202e}spoof","observations":[],"artifacts":[]},"after":{"summary":"","observations":[] ,"artifacts":[]}}), + serde_json::json!({"schemaVersion":1,"scenario":"x","outcome":"passed","before":{"summary":"","observations":[],"artifacts":[]},"after":{"summary":"","observations":[],"artifacts":[]},"sourceChanges":["lib/safe\u{2066}spoof.dart"]}), + serde_json::json!({"schemaVersion":1,"scenario":"x","outcome":"passed","before":{"summary":"","observations":[],"artifacts":[],"unknown":1},"after":{"summary":"","observations":[],"artifacts":[]}}), + ]; + for case in cases { + assert!( + record_at( + &project, + &env, + &serde_json::to_vec(&case).unwrap(), + UNIX_EPOCH + ) + .is_err(), + "{case}" + ); + } + let bidi_artifact = project.join("image\u{202e}.png"); + let bidi_input = envelope(&[("image", &bidi_artifact)], &[]); + assert!(matches!( + record_at(&project, &env, &bidi_input, UNIX_EPOCH), + Err(EvidenceError::Input(_)) + )); + assert!(matches!( + record_at(&project, &env, &[b'x'; 1024 * 1024 + 1], UNIX_EPOCH), + Err(EvidenceError::InputTooLarge) + )); + assert!(matches!( + record_at(&project, &env, &[0xff], UNIX_EPOCH), + Err(EvidenceError::InputUtf8) + )); + let mut receipt: serde_json::Value = + serde_json::from_slice(&std::fs::read(state.join("project.json")).unwrap()).unwrap(); + receipt["schemaVersion"] = 9.into(); + std::fs::write( + state.join("project.json"), + serde_json::to_vec(&receipt).unwrap(), + ) + .unwrap(); + assert!(matches!( + record_at(&project, &env, &valid, UNIX_EPOCH), + Err(EvidenceError::Receipt(_)) + )); + receipt["schemaVersion"] = 1.into(); + receipt["harnesses"] = serde_json::json!(["future-harness"]); + std::fs::write( + state.join("project.json"), + serde_json::to_vec(&receipt).unwrap(), + ) + .unwrap(); + assert!(matches!( + record_at(&project, &env, &valid, UNIX_EPOCH), + Err(EvidenceError::Receipt(_)) + )); +} + +#[cfg(windows)] +#[test] +fn windows_refuses_hardlinked_receipts_and_source_images() { + let (_temp, project, env, state) = fixture(); + let receipt = state.join("project.json"); + std::fs::hard_link(&receipt, state.join("receipt-link.json")).unwrap(); + let error = record_at(&project, &env, &envelope(&[], &[]), UNIX_EPOCH).unwrap_err(); + assert!(error.to_string().contains("hardlinked"), "{error}"); + + std::fs::remove_file(state.join("receipt-link.json")).unwrap(); + let image = project.join("source.png"); + std::fs::write(&image, b"\x89PNG\r\n\x1a\nimage").unwrap(); + std::fs::hard_link(&image, project.join("source-link.png")).unwrap(); + let error = record_at( + &project, + &env, + &envelope(&[("hardlink", &image)], &[]), + UNIX_EPOCH, + ) + .unwrap_err(); + assert!(error.to_string().contains("hardlinked"), "{error}"); +} + +#[test] +fn v1_flutter_receipt_remains_compatible_with_pack_v2_and_future_fields() { + let (_temp, project, env, state) = fixture(); + let receipt_path = state.join("project.json"); + let mut receipt: serde_json::Value = + serde_json::from_slice(&std::fs::read(&receipt_path).unwrap()).unwrap(); + assert_eq!(receipt["pack"]["version"], 2); + receipt["pack"]["version"] = 1.into(); + receipt["futureReceiptField"] = serde_json::json!({"ignored": true}); + receipt["pack"]["futurePackField"] = "ignored".into(); + std::fs::write(&receipt_path, serde_json::to_vec(&receipt).unwrap()).unwrap(); + + let result = record_at(&project, &env, &envelope(&[], &[]), UNIX_EPOCH).unwrap(); + assert!(Path::new(&result.evidence_path).is_file()); + + let mut request = InitRequest::new(&project); + request.pack = IntegrationPack::flutter(); + request.harnesses = vec![Harness::Codex]; + let update = plan_init(&request, &env).unwrap(); + assert!(apply_init(&update, "pack-v2-update").changed); + let updated: serde_json::Value = + serde_json::from_slice(&std::fs::read(receipt_path).unwrap()).unwrap(); + assert_eq!(updated["pack"]["version"], 2); +} + +#[test] +fn every_supported_secret_shape_is_absent_from_both_outputs() { + let (_temp, project, env, _state) = fixture(); + let secrets = "safe-before token: \"quotedsecret\" safe-middle password='singlesecret' api_key=abc123 {\"token\":\"jsonsecret\"} Authorization: Bearer authsecret safe-after eyJabc.def.ghi ghp_123456789 sk-123456789 AKIA1234567890123456 Cookie: sid=cookiesecret"; + let input = serde_json::to_vec(&serde_json::json!({"schemaVersion":1,"scenario":"Secrets test","outcome":"inconclusive","before":{"summary":secrets,"observations":[{"label":"safe","value":secrets}],"artifacts":[]},"after":{"summary":secrets,"observations":[],"artifacts":[]},"sourceChanges":[],"checks":[{"name":"check","status":"skipped","summary":secrets}],"limitations":[secrets]})).unwrap(); + let result = record_at(&project, &env, &input, UNIX_EPOCH).unwrap(); + let outputs = [ + std::fs::read_to_string(result.evidence_path).unwrap(), + std::fs::read_to_string(result.report_path).unwrap(), + ]; + assert!(outputs[0].contains("[REDACTED]"), "{}", outputs[0]); + assert!(outputs[1].contains("\\[REDACTED\\]"), "{}", outputs[1]); + for output in &outputs { + assert!( + output.contains("safe-before") && output.contains("safe-after"), + "{output}" + ); + } + for secret in [ + "quotedsecret", + "singlesecret", + "abc123", + "jsonsecret", + "authsecret", + "cookiesecret", + "eyJabc.def.ghi", + "ghp_123456789", + "sk-123456789", + "AKIA1234567890123456", + ] { + for output in &outputs { + assert!(!output.contains(secret), "{secret} leaked in {output}"); + } + } +} + +#[test] +fn enforces_image_magic_size_count_and_total_limits() { + let (_temp, project, env, state) = fixture(); + let oversized = state.parent().unwrap().join("oversized"); + let mut bytes = vec![0; 8 * 1024 * 1024 + 1]; + bytes[..8].copy_from_slice(b"\x89PNG\r\n\x1a\n"); + std::fs::write(&oversized, bytes).unwrap(); + assert!(record_at( + &project, + &env, + &envelope(&[("large", &oversized)], &[]), + UNIX_EPOCH + ) + .is_err()); + + let small = state.parent().unwrap().join("small"); + std::fs::write(&small, PNG).unwrap(); + let too_many = (0..9) + .map(|_| ("image", small.as_path())) + .collect::>(); + assert!(record_at(&project, &env, &envelope(&too_many, &too_many), UNIX_EPOCH).is_err()); + + let mut paths = Vec::new(); + for index in 0..9u8 { + let path = state.parent().unwrap().join(format!("large-{index}")); + let mut image = vec![index; 7_500_000]; + image[..8].copy_from_slice(b"\x89PNG\r\n\x1a\n"); + std::fs::write(&path, image).unwrap(); + paths.push(path); + } + let before = paths[..8] + .iter() + .map(|path| ("large", path.as_path())) + .collect::>(); + let after = [("large", paths[8].as_path())]; + assert!(record_at(&project, &env, &envelope(&before, &after), UNIX_EPOCH).is_err()); + assert!(std::fs::read_dir(state.join("runs")) + .unwrap() + .next() + .is_none()); +} + +#[cfg(unix)] +#[test] +fn refuses_symlink_hardlink_and_nonregular_artifacts() { + use std::os::unix::fs::symlink; + let (_temp, project, env, state) = fixture(); + let image = state.parent().unwrap().join("image"); + std::fs::write(&image, PNG).unwrap(); + let symlink_path = state.parent().unwrap().join("link"); + symlink(&image, &symlink_path).unwrap(); + let hardlink = state.parent().unwrap().join("hard"); + std::fs::hard_link(&image, &hardlink).unwrap(); + for source in [&symlink_path, &hardlink, state.parent().unwrap()] { + assert!(record_at( + &project, + &env, + &envelope(&[("bad", source)], &[]), + UNIX_EPOCH + ) + .is_err()); + } +} diff --git a/crates/pickforge-cli/tests/init.rs b/crates/pickforge-cli/tests/init.rs index dcd0ec9..acecc53 100644 --- a/crates/pickforge-cli/tests/init.rs +++ b/crates/pickforge-cli/tests/init.rs @@ -121,6 +121,18 @@ fn nonempty_unowned_state_directory_is_refused() { ); } +#[test] +fn runs_directory_without_a_receipt_is_foreign() { + let (_temp, project, env) = fixture(); + let request = InitRequest::new(&project); + let initial = plan_init(&request, &env).unwrap(); + let runs = Path::new(&initial.report.state_dir).join("runs"); + std::fs::create_dir_all(&runs).unwrap(); + let error = plan_init(&request, &env).unwrap_err().to_string(); + assert!(error.contains("non-empty but has no Pickforge project receipt")); + assert!(runs.is_dir()); +} + #[test] fn empty_or_owned_interruption_state_allows_receipt_recovery() { let (_temp, project, env) = fixture(); @@ -294,6 +306,7 @@ fn flutter_pack_plans_owned_harness_configs_and_deduplicated_workflows_in_order( let before = std::fs::read(project.join("pubspec.yaml")).unwrap(); let plan = plan_init(&request, &env).unwrap(); assert_eq!(plan.report.pack.name, "pickforge-flutter"); + assert_eq!(plan.report.pack.version, 2); assert_eq!(plan.report.actions.len(), 6); assert!(plan.report.actions[0].target.ends_with(".claude.json")); assert!(plan.report.actions[1].target.ends_with("config.toml")); diff --git a/docs/releases/UNRELEASED.md b/docs/releases/UNRELEASED.md index 837a42a..f55dae1 100644 --- a/docs/releases/UNRELEASED.md +++ b/docs/releases/UNRELEASED.md @@ -15,6 +15,13 @@ GitHub release description, then reset it after the release is published. only) overrides the state root, which defaults to `~/.pickforge/pickforge`. Project paths that cannot satisfy the shared UTF-8 project-id contract fail closed without resolving a state directory. +- Experimental, unpublished `pickforge evidence record` accepts a bounded JSON + document from stdin or `--input`, verifies the existing Flutter init receipt, + copies validated screenshots into an external private run directory, and writes + canonical `evidence.json` plus Markdown `report.md`. Text secrets are redacted, + Markdown is escaped without changing canonical JSON strings, and the command + accepts a valid owned Flutter receipt v1 or newer and fails closed when it is + missing or belongs elsewhere; the Flutter integration pack itself is now v2. - Experimental, unpublished `pickforge init` foundation adds read-only planning, dry-run/JSON reports, deterministic external project receipts, and transactional adapter config writes for Claude Code, Codex, and Pi. The base @@ -49,15 +56,17 @@ GitHub release description, then reset it after the release is published. - Pinned Bun 1.3.12 CI: frozen install, typecheck, lint, 1,133 tests pass, one skips, coverage passes at 82.48% lines, and build passes. - The pinned OSV Scanner v2.3.8 image reports no unfiltered advisories. -- `cargo fmt --check`, `cargo clippy --workspace --all-targets --locked -- -D - warnings`, and `cargo test --workspace --locked` pass with 65 tests covering +- `cargo fmt --all --check`, `cargo clippy --workspace --all-targets --locked -- -D + warnings`, and `cargo test --workspace --locked` pass with 78 tests covering project/framework detection, tool and harness discovery, state and project-id boundaries, adapter preservation/refusal, transaction rollback and drift, dry-run, receipt ownership, file modes, idempotency, Git-tree cleanliness, JSON/text safety, CLI exits, owned Flutter MCP configuration, per-harness - arguments, workflow targeting/deduplication, and alpha tool preconditions. The - Windows MSVC target also passes cross-target check and clippy; Windows-native - tests run in the CI matrix. + arguments, workflow targeting/deduplication, alpha tool preconditions, evidence + storage/redaction, receipt compatibility, and concurrent first use. Exact local + validation also includes `cargo check --workspace --all-targets --locked + --target x86_64-pc-windows-msvc` and target-specific clippy with `-D warnings`. + Windows-native tests run in the CI matrix. - Manual smoke runs of `pickforge doctor` and `pickforge doctor --json` against temporary fake Flutter and non-Flutter projects with an isolated `PATH`/`PICKFORGE_HOME`.