diff --git a/README.md b/README.md index 1211c76..c4b9a1b 100644 --- a/README.md +++ b/README.md @@ -38,8 +38,10 @@ Per-release detail (objective, shipped capabilities, compatibility, known limitations) lives in [docs/releases/](docs/releases/); active, incomplete work is in the [roadmap](docs/roadmap/README.md). -Live downstream release-lane dogfood, sessions, GPU/AI, and broader DCC matrices -are still ahead. +The active **v0.20.0** milestone closes downstream package/product +reproducibility and renderer-workflow findings. DCC host integration is +explicitly deferred to **v0.21.0**; sessions, GPU/AI, and broader DCC matrices +remain ahead. Linux x86_64 is the first-class target; other OS targets are modeled and partially working — these examples were exercised on Windows. diff --git a/crates/ost-cli/src/commands/build.rs b/crates/ost-cli/src/commands/build.rs index 2ecb21b..274a377 100644 --- a/crates/ost-cli/src/commands/build.rs +++ b/crates/ost-cli/src/commands/build.rs @@ -401,7 +401,7 @@ fn run_resolved(args: BuildArgs, fmt: Format, domain_intent: Option ) { Ok(status) => status, Err(error) => { - stamp_build_renderer_reports( + let stamp = stamp_build_renderer_reports( &root, &build_dir, &renderer_reports_before, @@ -411,12 +411,17 @@ fn run_resolved(args: BuildArgs, fmt: Format, domain_intent: Option None, ost_manifest::SessionOutcome::Incomplete, false, - )?; + ); + // A timed-out child has already been reaped by the reporter. Clear + // the owning record too, so retry does not inherit a stale lease + // from a failure OST handled deterministically. + lease.release(); + stamp?; return Err(error); } }; if !configure_status.success() { - stamp_build_renderer_reports( + let stamp = stamp_build_renderer_reports( &root, &build_dir, &renderer_reports_before, @@ -426,14 +431,18 @@ fn run_resolved(args: BuildArgs, fmt: Format, domain_intent: Option Some(crate::commands::renderer::unix_now()), ost_manifest::SessionOutcome::Failure, false, - )?; + ); if args.nested { + lease.release(); + stamp?; return Err(Error::external_tool(format!( "managed CMake configure failed{}", exit_detail(&configure_status) )) .with_phase("configure")); } + lease.release(); + stamp?; rep.exit_unsuccessful(configure_status); } rep.phase("Building targets"); @@ -446,7 +455,7 @@ fn run_resolved(args: BuildArgs, fmt: Format, domain_intent: Option ) { Ok(status) => status, Err(error) => { - stamp_build_renderer_reports( + let stamp = stamp_build_renderer_reports( &root, &build_dir, &renderer_reports_before, @@ -456,12 +465,14 @@ fn run_resolved(args: BuildArgs, fmt: Format, domain_intent: Option None, ost_manifest::SessionOutcome::Incomplete, false, - )?; + ); + lease.release(); + stamp?; return Err(error); } }; if !build_status.success() { - stamp_build_renderer_reports( + let stamp = stamp_build_renderer_reports( &root, &build_dir, &renderer_reports_before, @@ -471,14 +482,18 @@ fn run_resolved(args: BuildArgs, fmt: Format, domain_intent: Option Some(crate::commands::renderer::unix_now()), ost_manifest::SessionOutcome::Failure, false, - )?; + ); if args.nested { + lease.release(); + stamp?; return Err(Error::external_tool(format!( "managed CMake build failed{}", exit_detail(&build_status) )) .with_phase("build")); } + lease.release(); + stamp?; rep.exit_unsuccessful(build_status); } @@ -486,7 +501,7 @@ fn run_resolved(args: BuildArgs, fmt: Format, domain_intent: Option // tree means the preset built nothing useful. rep.phase("Verifying outputs"); if let Err(error) = verify_build(&build_dir) { - stamp_build_renderer_reports( + let stamp = stamp_build_renderer_reports( &root, &build_dir, &renderer_reports_before, @@ -496,7 +511,9 @@ fn run_resolved(args: BuildArgs, fmt: Format, domain_intent: Option Some(crate::commands::renderer::unix_now()), ost_manifest::SessionOutcome::Failure, false, - )?; + ); + lease.release(); + stamp?; return Err(error); } let completed_unix = crate::commands::renderer::unix_now(); @@ -513,10 +530,9 @@ fn run_resolved(args: BuildArgs, fmt: Format, domain_intent: Option )?; write_completion(&root, &id, &relative_build, &intent, lease.invocation())?; - // The completion is published; the target is free for the next writer. On - // any earlier `?` the lease drops instead, which releases the lock without - // clearing the record — leaving the evidence of an interrupted run for the - // next invocation to report as a takeover. + // The completion is published; the target is free for the next writer. + // Handled configure/build failures also clear their record before return; + // only an actually interrupted process leaves takeover evidence behind. lease.release(); rep.done(); diff --git a/crates/ost-cli/src/commands/plugin.rs b/crates/ost-cli/src/commands/plugin.rs index 435639c..aec568c 100644 --- a/crates/ost-cli/src/commands/plugin.rs +++ b/crates/ost-cli/src/commands/plugin.rs @@ -17,13 +17,15 @@ //! The CLI stays thin: it resolves paths and the runtime, then calls into //! `ost-plugin` for the model, checks, execution levels, and report shapes. -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; +use std::fs::File; use std::path::PathBuf; use std::process::Command; use std::time::{SystemTime, UNIX_EPOCH}; use camino::{Utf8Path, Utf8PathBuf}; use clap::Subcommand; +use serde::Deserialize; use ost_build::{ pack_dir_with, stage_files, BuildCompletion, BuildIntent, BuildOutput, BuildProjectIdentity, @@ -144,6 +146,9 @@ pub enum PluginCmd { #[arg(long)] allow_unmanaged_output: bool, }, + /// Verify or install an aggregate plugin product artifact. + #[command(subcommand)] + Product(ProductCmd), /// Publish a packaged plugin artifact into the local registry (by digest). Publish { /// Path to the bundle directory. @@ -265,6 +270,29 @@ pub enum PluginCmd { }, } +#[derive(Debug, Subcommand)] +pub enum ProductCmd { + /// Verify the product archive and every member archive/checksum. + Verify { + /// Product dist directory, manifest.json, or product .tar.zst archive. + product: String, + /// Require the outer product archive to have this full sha256 digest. + #[arg(long)] + expect_digest: Option, + }, + /// Verify and install every member in dependency order. + Install { + /// Product dist directory, manifest.json, or product .tar.zst archive. + product: String, + /// New installation root. The command refuses to overwrite it. + #[arg(long)] + prefix: String, + /// Require the outer product archive to have this full sha256 digest. + #[arg(long)] + expect_digest: Option, + }, +} + pub fn run(cmd: PluginCmd, fmt: Format) -> Result<()> { match cmd { PluginCmd::New { @@ -339,6 +367,7 @@ pub fn run(cmd: PluginCmd, fmt: Format) -> Result<()> { "missing bundle path (or pass --workspace to package every bundle)", )), }, + PluginCmd::Product(command) => product(command, fmt), PluginCmd::Publish { bundle, target, @@ -1964,15 +1993,12 @@ fn package_bundle( .map(|m| m.validation.as_str().to_string()) .unwrap_or_else(|| "unknown".into()); - // Under SOURCE_DATE_EPOCH the whole dist output — archive *and* manifest — - // must reproduce, so pin `created_unix` to it too; otherwise stamp wall-clock - // provenance. - let created = ost_build::source_date_epoch_opt().unwrap_or_else(|| { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_secs()) - .unwrap_or(0) - }); + // `manifest.json` is itself embedded by aggregate products. A wall-clock + // value here made two identical `--workspace --product` invocations produce + // different product bytes even though every member archive was stable. + // Use the same reproducible timestamp contract as tar entries: an explicit + // SOURCE_DATE_EPOCH, otherwise epoch 0. + let created = ost_build::source_date_epoch(); let files_json: Vec<_> = packed.files.iter().map(|f| f.manifest_json()).collect(); // A sibling `*-debug` package, when symbols were split out: its own archive // digest/size and file list, so a consumer can pull and overlay it to restore @@ -2433,8 +2459,10 @@ fn package_workspace_product( "target": target, "install": { "layout": "members//", + "destination": "bundles//", "order": order, - "contract": "verify each member SHA256SUMS, then extract its archive in dependency order", + "activation": "openstrata.activation.json", + "contract": "run `ost plugin product verify`, then `ost plugin product install --prefix `; members are verified and installed in dependency order", }, "members": members, }); @@ -2465,12 +2493,7 @@ fn package_workspace_product( let packed = pack_dir_with(&stage, &archive_path, &staged, pack_opts, &mut |_| {}) .map_err(|e| Error::io(archive_path.to_string(), e))?; - let created = ost_build::source_date_epoch_opt().unwrap_or_else(|| { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|duration| duration.as_secs()) - .unwrap_or(0) - }); + let created = ost_build::source_date_epoch(); let mut provenance = first .manifest .get("provenance") @@ -2522,6 +2545,939 @@ fn package_workspace_product( }) } +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct PluginProductContract { + schema: String, + name: String, + version: String, + target: String, + install: PluginProductInstall, + members: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct PluginProductInstall { + layout: String, + order: Vec, + contract: String, + #[serde(default = "default_product_destination")] + destination: String, + #[serde(default = "default_product_activation")] + activation: String, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct PluginProductMember { + id: String, + position: usize, + name: String, + version: String, + kind: String, + archive: String, + archive_digest: String, + archive_size: u64, + manifest: String, + checksums: String, + evidence: Vec, + debug: RequiredProductDebug, + #[serde(rename = "dependencies")] + dependencies: serde_json::Value, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct PluginProductDebug { + archive: String, + archive_digest: String, + archive_size: u64, +} + +#[derive(Debug, Deserialize)] +#[serde(transparent)] +struct RequiredProductDebug(Option); + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct ProductMemberActivation { + schema: String, + target_os: String, + root: String, + environment: serde_json::Value, + plugin_paths: Vec, + library_paths: Vec, + python_paths: Vec, + entrypoints: serde_json::Value, + python_dll_search: serde_json::Value, +} + +#[derive(Debug)] +struct ProductArchiveSource { + archive: Utf8PathBuf, + digest: String, + size: u64, +} + +#[derive(Debug)] +struct TemporaryProductTree { + path: Utf8PathBuf, + remove_on_drop: bool, +} + +impl Drop for TemporaryProductTree { + fn drop(&mut self) { + if self.remove_on_drop { + let _ = std::fs::remove_dir_all(self.path.as_std_path()); + } + } +} + +struct VerifiedPluginProduct { + source: ProductArchiveSource, + tree: TemporaryProductTree, + contract: PluginProductContract, +} + +fn default_product_destination() -> String { + "bundles//".into() +} + +fn default_product_activation() -> String { + "openstrata.activation.json".into() +} + +fn product(command: ProductCmd, fmt: Format) -> Result<()> { + match command { + ProductCmd::Verify { + product, + expect_digest, + } => { + let verified = verify_plugin_product(&product, expect_digest.as_deref())?; + report_product_verification(&verified, fmt); + Ok(()) + } + ProductCmd::Install { + product, + prefix, + expect_digest, + } => install_plugin_product(&product, &prefix, expect_digest.as_deref(), fmt), + } +} + +fn verify_plugin_product( + product: &str, + expect_digest: Option<&str>, +) -> Result { + let source = resolve_product_archive(product, expect_digest)?; + let tree = temporary_product_tree(std::env::temp_dir().as_path(), "verify")?; + ost_artifact::extract_archive(&source.archive, &source.digest, &tree.path)?; + + let contract_path = tree.path.join("openstrata.product.json"); + let source_text = std::fs::read_to_string(contract_path.as_std_path()) + .map_err(|error| Error::io(contract_path.to_string(), error))?; + let contract: PluginProductContract = serde_json::from_str(&source_text) + .map_err(|error| Error::parse(contract_path.to_string(), anyhow::Error::new(error)))?; + validate_product_contract(&contract)?; + + for member in &contract.members { + verify_product_member(&tree.path, &contract.target, member)?; + } + + Ok(VerifiedPluginProduct { + source, + tree, + contract, + }) +} + +fn resolve_product_archive( + product: &str, + expect_digest: Option<&str>, +) -> Result { + if let Some(digest) = expect_digest { + validate_sha256_digest(digest, "--expect-digest")?; + } + let input = Utf8PathBuf::from(product); + if !input.as_std_path().exists() { + return Err(Error::precondition(format!( + "plugin product path does not exist: {input}" + ))); + } + let explicit_archive = + input.as_std_path().is_file() && input.file_name() != Some("manifest.json"); + + let manifest_path = if input.as_std_path().is_dir() { + Some(input.join("manifest.json")) + } else if input.file_name() == Some("manifest.json") { + Some(input.clone()) + } else { + let sibling = input + .parent() + .unwrap_or_else(|| Utf8Path::new(".")) + .join("manifest.json"); + sibling.as_std_path().is_file().then_some(sibling) + }; + + if let Some(manifest_path) = manifest_path.filter(|path| path.as_std_path().is_file()) { + let source = match std::fs::read_to_string(manifest_path.as_std_path()) { + Ok(source) => source, + Err(_) if explicit_archive => { + return product_archive_from_file(&input, expect_digest); + } + Err(error) => return Err(Error::io(manifest_path.to_string(), error)), + }; + let manifest: serde_json::Value = match serde_json::from_str(&source) { + Ok(manifest) => manifest, + Err(_) if explicit_archive => { + return product_archive_from_file(&input, expect_digest); + } + Err(error) => { + return Err(Error::parse( + manifest_path.to_string(), + anyhow::Error::new(error), + )); + } + }; + if manifest["kind"] != ost_artifact::PLUGIN_PRODUCT_KIND { + if explicit_archive { + return product_archive_from_file(&input, expect_digest); + } + return Err(Error::validation(format!( + "'{manifest_path}' is not an aggregate plugin product manifest" + ))); + } + let archive_name = match manifest["archive"].as_str() { + Some(archive_name) => archive_name, + None if explicit_archive => { + return product_archive_from_file(&input, expect_digest); + } + None => { + return Err(Error::validation(format!( + "'{manifest_path}' is missing string field 'archive'" + ))); + } + }; + if explicit_archive && input.file_name() != Some(archive_name) { + // An explicitly named archive is authoritative unless the sibling + // product manifest actually names that archive. + return product_archive_from_file(&input, expect_digest); + } + let digest = manifest["archive_digest"].as_str().ok_or_else(|| { + Error::validation(format!( + "'{manifest_path}' is missing string field 'archive_digest'" + )) + })?; + validate_sha256_digest(digest, "manifest archive_digest")?; + if expect_digest.is_some_and(|expected| expected != digest) { + return Err(Error::coded( + "PLUGIN_PRODUCT_DIGEST_MISMATCH", + Category::Validation, + format!( + "product manifest pins {digest}, but --expect-digest requested {}", + expect_digest.unwrap_or_default() + ), + )); + } + let root = manifest_path.parent().unwrap_or_else(|| Utf8Path::new(".")); + let archive = safe_product_join(root, archive_name, "manifest archive")?; + let (actual, size) = digest_file(&archive)?; + if actual != digest { + return Err(product_digest_mismatch(&archive, digest, &actual)); + } + return Ok(ProductArchiveSource { + archive, + digest: digest.to_string(), + size, + }); + } + + if input.as_std_path().is_dir() { + return Err(Error::precondition(format!( + "product directory '{input}' has no manifest.json" + ))); + } + product_archive_from_file(&input, expect_digest) +} + +fn product_archive_from_file( + archive: &Utf8Path, + expect_digest: Option<&str>, +) -> Result { + let (actual, size) = digest_file(archive)?; + if let Some(expected) = expect_digest { + if actual != expected { + return Err(product_digest_mismatch(archive, expected, &actual)); + } + } + Ok(ProductArchiveSource { + archive: archive.to_path_buf(), + digest: actual, + size, + }) +} + +fn product_digest_mismatch(path: &Utf8Path, expected: &str, actual: &str) -> Error { + Error::coded( + "PLUGIN_PRODUCT_DIGEST_MISMATCH", + Category::Validation, + format!("product archive '{path}' hashes to {actual}, expected {expected}"), + ) +} + +fn digest_file(path: &Utf8Path) -> Result<(String, u64)> { + let mut file = + File::open(path.as_std_path()).map_err(|error| Error::io(path.to_string(), error))?; + ost_core::digest::sha256_hex_reader(&mut file) + .map_err(|error| Error::io(path.to_string(), error)) +} + +fn validate_sha256_digest(digest: &str, field: &str) -> Result<()> { + let Some(hex) = digest.strip_prefix("sha256:") else { + return Err(Error::validation(format!( + "{field} must be a full sha256:<64 hex> digest" + ))); + }; + if hex.len() != 64 || !hex.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Err(Error::validation(format!( + "{field} must be a full sha256:<64 hex> digest" + ))); + } + Ok(()) +} + +fn validate_product_contract(contract: &PluginProductContract) -> Result<()> { + if contract.schema != "openstrata.plugin-product/v1alpha1" { + return Err(Error::config(format!( + "unsupported plugin product schema '{}'", + contract.schema + ))); + } + for (field, value) in [ + ("name", contract.name.as_str()), + ("version", contract.version.as_str()), + ("target", contract.target.as_str()), + ] { + if value.trim().is_empty() { + return Err(Error::validation(format!( + "plugin product {field} must not be empty" + ))); + } + } + if contract.install.layout != "members//" { + return Err(Error::validation(format!( + "unsupported product archive layout '{}'", + contract.install.layout + ))); + } + if contract.install.destination != "bundles//" { + return Err(Error::validation(format!( + "unsupported product installation layout '{}'", + contract.install.destination + ))); + } + if contract.install.activation != "openstrata.activation.json" { + return Err(Error::validation(format!( + "unsupported product activation contract '{}'", + contract.install.activation + ))); + } + if contract.install.contract.trim().is_empty() { + return Err(Error::validation( + "plugin product install.contract must not be empty", + )); + } + if contract.members.is_empty() { + return Err(Error::validation( + "plugin product must contain at least one member", + )); + } + + let mut ids = BTreeSet::new(); + for (position, member) in contract.members.iter().enumerate() { + validate_product_member_identity(member)?; + if member.position != position { + return Err(Error::validation(format!( + "product member '{}' has position {}, expected {position}", + member.id, member.position + ))); + } + if !ids.insert(member.id.clone()) { + return Err(Error::validation(format!( + "plugin product repeats member id '{}'", + member.id + ))); + } + } + let member_order = contract + .members + .iter() + .map(|member| member.id.as_str()) + .collect::>(); + let install_order = contract + .install + .order + .iter() + .map(String::as_str) + .collect::>(); + if install_order != member_order { + return Err(Error::validation(format!( + "plugin product install order {install_order:?} does not match member order {member_order:?}" + ))); + } + Ok(()) +} + +fn validate_product_member_identity(member: &PluginProductMember) -> Result<()> { + if member.id.is_empty() + || member.id != member.name + || member + .id + .bytes() + .any(|byte| !(byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))) + { + return Err(Error::validation(format!( + "plugin product member id/name must be one matching portable identity, got id='{}' name='{}'", + member.id, member.name + ))); + } + for (field, value) in [ + ("version", member.version.as_str()), + ("kind", member.kind.as_str()), + ] { + if value.trim().is_empty() { + return Err(Error::validation(format!( + "product member '{}' {field} must not be empty", + member.id + ))); + } + } + validate_sha256_digest( + &member.archive_digest, + &format!("product member '{}' archive_digest", member.id), + )?; + if member.evidence.is_empty() { + return Err(Error::validation(format!( + "product member '{}' must carry at least one evidence file", + member.id + ))); + } + if !member.dependencies.is_null() && !member.dependencies.is_object() { + return Err(Error::validation(format!( + "product member '{}' dependencies must be an object or null", + member.id + ))); + } + if let Some(debug) = &member.debug.0 { + validate_sha256_digest( + &debug.archive_digest, + &format!("product member '{}' debug archive_digest", member.id), + )?; + } + Ok(()) +} + +fn verify_product_member( + root: &Utf8Path, + product_target: &str, + member: &PluginProductMember, +) -> Result<()> { + let member_prefix = format!("members/{}/", member.id); + for (field, relative) in [ + ("archive", member.archive.as_str()), + ("manifest", member.manifest.as_str()), + ("checksums", member.checksums.as_str()), + ] { + if !relative.starts_with(&member_prefix) { + return Err(Error::validation(format!( + "product member '{}' {field} must stay under '{member_prefix}'", + member.id + ))); + } + } + let archive = safe_product_join(root, &member.archive, "product member archive")?; + let (digest, size) = digest_file(&archive)?; + if digest != member.archive_digest || size != member.archive_size { + return Err(Error::coded( + "PLUGIN_PRODUCT_MEMBER_DIGEST_MISMATCH", + Category::Validation, + format!( + "product member '{}' archive is {digest} ({size} bytes), expected {} ({} bytes)", + member.id, member.archive_digest, member.archive_size + ), + )); + } + + let member_manifest = safe_product_join(root, &member.manifest, "product member manifest")?; + let manifest_source = std::fs::read_to_string(member_manifest.as_std_path()) + .map_err(|error| Error::io(member_manifest.to_string(), error))?; + let manifest: serde_json::Value = serde_json::from_str(&manifest_source) + .map_err(|error| Error::parse(member_manifest.to_string(), anyhow::Error::new(error)))?; + verify_product_member_manifest(product_target, member, &manifest)?; + + let checksums = safe_product_join(root, &member.checksums, "product member checksums")?; + verify_member_checksums( + checksums + .parent() + .ok_or_else(|| Error::validation("product member checksums have no parent"))?, + &checksums, + archive + .file_name() + .ok_or_else(|| Error::validation("product member archive has no filename"))?, + )?; + + for evidence in &member.evidence { + if !evidence.starts_with(&member_prefix) { + return Err(Error::validation(format!( + "product member '{}' evidence path must stay under '{member_prefix}'", + member.id + ))); + } + let path = safe_product_join(root, evidence, "product member evidence")?; + if !path.as_std_path().is_file() { + return Err(Error::validation(format!( + "product member '{}' is missing evidence '{evidence}'", + member.id + ))); + } + } + if let Some(debug) = &member.debug.0 { + if !debug.archive.starts_with(&member_prefix) { + return Err(Error::validation(format!( + "product member '{}' debug archive must stay under '{member_prefix}'", + member.id + ))); + } + let path = safe_product_join(root, &debug.archive, "product member debug archive")?; + let (actual, size) = digest_file(&path)?; + if actual != debug.archive_digest || size != debug.archive_size { + return Err(Error::coded( + "PLUGIN_PRODUCT_MEMBER_DIGEST_MISMATCH", + Category::Validation, + format!( + "product member '{}' debug archive is {actual} ({size} bytes), expected {} ({} bytes)", + member.id, debug.archive_digest, debug.archive_size + ), + )); + } + } + + let expanded = root.join("expanded").join(&member.id); + ost_artifact::extract_archive(&archive, &member.archive_digest, &expanded)?; + verify_member_manifest_files(&expanded, &manifest)?; + Bundle::load(&expanded).map_err(|error| { + Error::validation(format!( + "installed product member '{}' is not a valid plugin bundle: {error}", + member.id + )) + })?; + Ok(()) +} + +fn verify_product_member_manifest( + product_target: &str, + member: &PluginProductMember, + manifest: &serde_json::Value, +) -> Result<()> { + let expected_archive = Utf8Path::new(&member.archive) + .file_name() + .unwrap_or_default(); + let checks = [ + ( + "kind", + manifest["kind"].as_str(), + Some(ost_artifact::PLUGIN_BUNDLE_KIND), + ), + ( + "plugin.name", + manifest + .pointer("/plugin/name") + .and_then(|value| value.as_str()), + Some(member.name.as_str()), + ), + ( + "plugin.version", + manifest + .pointer("/plugin/version") + .and_then(|value| value.as_str()), + Some(member.version.as_str()), + ), + ( + "plugin.kind", + manifest + .pointer("/plugin/kind") + .and_then(|value| value.as_str()), + Some(member.kind.as_str()), + ), + ("target", manifest["target"].as_str(), Some(product_target)), + ( + "archive", + manifest["archive"].as_str(), + Some(expected_archive), + ), + ( + "archive_digest", + manifest["archive_digest"].as_str(), + Some(member.archive_digest.as_str()), + ), + ]; + for (field, actual, expected) in checks { + if actual != expected { + return Err(Error::validation(format!( + "product member '{}' manifest {field} is {actual:?}, expected {expected:?}", + member.id + ))); + } + } + Ok(()) +} + +fn verify_member_checksums( + member_root: &Utf8Path, + checksums: &Utf8Path, + required_archive: &str, +) -> Result<()> { + let source = std::fs::read_to_string(checksums.as_std_path()) + .map_err(|error| Error::io(checksums.to_string(), error))?; + let mut paths = BTreeSet::new(); + for (line_number, line) in source.lines().enumerate() { + if line.trim().is_empty() { + continue; + } + let (expected, relative) = line.split_once(" ").ok_or_else(|| { + Error::validation(format!( + "'{checksums}' line {} is not ' '", + line_number + 1 + )) + })?; + if expected.len() != 64 || !expected.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Err(Error::validation(format!( + "'{checksums}' line {} has an invalid SHA-256", + line_number + 1 + ))); + } + if !paths.insert(relative.to_string()) { + return Err(Error::validation(format!( + "'{checksums}' repeats path '{relative}'" + ))); + } + let path = safe_product_join(member_root, relative, "member checksum path")?; + let (actual, _) = digest_file(&path)?; + if bare_sha256(&actual) != expected { + return Err(Error::coded( + "PLUGIN_PRODUCT_MEMBER_CHECKSUM_MISMATCH", + Category::Validation, + format!( + "member file '{relative}' hashes to {}, expected sha256:{expected}", + bare_sha256(&actual) + ), + )); + } + } + if !paths.contains(required_archive) { + return Err(Error::validation(format!( + "'{checksums}' does not cover member archive '{required_archive}'" + ))); + } + Ok(()) +} + +fn verify_member_manifest_files(root: &Utf8Path, manifest: &serde_json::Value) -> Result<()> { + let files = manifest["files"].as_array().ok_or_else(|| { + Error::validation("product member manifest is missing array field 'files'") + })?; + for entry in files { + let relative = entry["path"].as_str().ok_or_else(|| { + Error::validation("product member manifest file entry is missing string 'path'") + })?; + let expected = entry["sha256"].as_str().ok_or_else(|| { + Error::validation(format!( + "product member manifest entry '{relative}' is missing string 'sha256'" + )) + })?; + validate_sha256_digest(expected, "product member file sha256")?; + let expected_size = entry["size"].as_u64().ok_or_else(|| { + Error::validation(format!( + "product member manifest entry '{relative}' is missing integer 'size'" + )) + })?; + let path = safe_product_join(root, relative, "product member file")?; + let (actual, size) = digest_file(&path)?; + if actual != expected || size != expected_size { + return Err(Error::coded( + "PLUGIN_PRODUCT_MEMBER_FILE_MISMATCH", + Category::Validation, + format!( + "installed member file '{relative}' is {actual} ({size} bytes), expected {expected} ({expected_size} bytes)" + ), + )); + } + } + Ok(()) +} + +fn safe_product_join(root: &Utf8Path, relative: &str, field: &str) -> Result { + let bytes = relative.as_bytes(); + let has_drive = bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':'; + if relative.is_empty() + || relative.starts_with('/') + || relative.starts_with('\\') + || relative.contains('\\') + || relative.contains(':') + || has_drive + || relative + .split('/') + .any(|component| component.is_empty() || matches!(component, "." | "..")) + { + return Err(Error::validation(format!( + "{field} must be a portable path below the product root, got '{relative}'" + ))); + } + Ok(root.join(relative)) +} + +fn temporary_product_tree(parent: &std::path::Path, label: &str) -> Result { + let parent = Utf8PathBuf::from_path_buf(parent.to_path_buf()).map_err(|path| { + Error::config(format!( + "temporary directory is not UTF-8: {}", + path.display() + )) + })?; + std::fs::create_dir_all(parent.as_std_path()) + .map_err(|error| Error::io(parent.to_string(), error))?; + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_nanos()) + .unwrap_or(0); + let path = parent.join(format!( + ".ost-plugin-product-{label}-{}-{nonce}", + std::process::id() + )); + std::fs::create_dir(&path).map_err(|error| Error::io(path.to_string(), error))?; + Ok(TemporaryProductTree { + path, + remove_on_drop: true, + }) +} + +fn install_plugin_product( + product: &str, + prefix: &str, + expect_digest: Option<&str>, + fmt: Format, +) -> Result<()> { + let verified = verify_plugin_product(product, expect_digest)?; + let prefix = Utf8PathBuf::from(prefix); + if prefix.as_std_path().exists() { + return Err(Error::precondition(format!( + "product installation prefix already exists: {prefix}" + )) + .with_hint( + "choose a new empty prefix; product install never overwrites an existing tree", + )); + } + let parent = prefix + .parent() + .filter(|parent| !parent.as_str().is_empty()) + .unwrap_or_else(|| Utf8Path::new(".")); + let mut staging = temporary_product_tree(parent.as_std_path(), "install")?; + + for member in &verified.contract.members { + let expanded = verified.tree.path.join("expanded").join(&member.id); + let destination = Utf8Path::new("bundles").join(&member.id); + copy_tree_required(&expanded, &destination, &staging.path)?; + } + write_product_activation(&staging.path, &verified.contract)?; + let receipt = serde_json::json!({ + "schema": "openstrata.plugin-product-install/v1alpha1", + "name": verified.contract.name, + "version": verified.contract.version, + "target": verified.contract.target, + "archive_digest": verified.source.digest, + "members": verified.contract.install.order, + "layout": verified.contract.install.destination, + "activation": verified.contract.install.activation, + }); + write_text( + &staging.path.join("openstrata.product-install.json"), + &pretty_json(&receipt)?, + )?; + std::fs::rename(staging.path.as_std_path(), prefix.as_std_path()) + .map_err(|error| Error::io(format!("{} -> {prefix}", staging.path), error))?; + // The path moved successfully; the installed tree now belongs to the user. + staging.remove_on_drop = false; + + if fmt.is_json() { + output::success(&serde_json::json!({ + "installed": true, + "name": verified.contract.name, + "version": verified.contract.version, + "target": verified.contract.target, + "archive_digest": verified.source.digest, + "prefix": prefix, + "members": verified.contract.install.order, + "activation": prefix.join("openstrata.activation.json"), + })); + } else { + println!( + "Installed plugin product {} {} for {}", + verified.contract.name, verified.contract.version, verified.contract.target + ); + println!(" digest: {}", verified.source.digest); + println!(" prefix: {prefix}"); + println!( + " members: {} ({})", + verified.contract.members.len(), + verified.contract.install.order.join(", ") + ); + println!( + " activation: {}", + prefix.join("openstrata.activation.json") + ); + } + Ok(()) +} + +fn write_product_activation(root: &Utf8Path, contract: &PluginProductContract) -> Result<()> { + let mut target_os: Option = None; + let mut plugin_paths = Vec::new(); + let mut library_paths = Vec::new(); + let mut python_paths = Vec::new(); + for member in &contract.members { + let member_root = root.join("bundles").join(&member.id); + let path = member_root.join("openstrata.activation.json"); + let source = std::fs::read_to_string(path.as_std_path()) + .map_err(|error| Error::io(path.to_string(), error))?; + let activation: ProductMemberActivation = serde_json::from_str(&source) + .map_err(|error| Error::parse(path.to_string(), anyhow::Error::new(error)))?; + if activation.schema != "openstrata.activation/v1alpha1" || activation.root != "." { + return Err(Error::validation(format!( + "product member '{}' has unsupported activation contract", + member.id + ))); + } + let os = parse_product_os(&activation.target_os)?; + if target_os.is_some_and(|selected| selected != os) { + return Err(Error::validation( + "plugin product members target different operating systems", + )); + } + target_os = Some(os); + // Deserializing the complete strict member contract above ensures these + // fields were present even though the merged contract computes them. + let _ = ( + &activation.environment, + &activation.entrypoints, + &activation.python_dll_search, + ); + extend_product_activation_paths(&mut plugin_paths, &member.id, &activation.plugin_paths)?; + extend_product_activation_paths(&mut library_paths, &member.id, &activation.library_paths)?; + extend_product_activation_paths(&mut python_paths, &member.id, &activation.python_paths)?; + } + let target_os = target_os + .ok_or_else(|| Error::validation("cannot write activation for an empty plugin product"))?; + let loader_env = activation_loader_key(target_os); + let activation = serde_json::json!({ + "schema": "openstrata.activation/v1alpha1", + "target_os": target_os.as_str(), + "root": ".", + "environment": { + "plugin": "PXR_PLUGINPATH_NAME", + "loader": loader_env, + "python": "PYTHONPATH", + }, + "plugin_paths": plugin_paths, + "library_paths": library_paths, + "python_paths": python_paths, + "entrypoints": { + "powershell": "activate.ps1", + "bash": "activate.sh", + "python": "openstrata_activate.py", + }, + "python_dll_search": { + "windows": "import openstrata_activate before importing pxr; the module retains os.add_dll_directory handles", + }, + }); + write_text( + &root.join("openstrata.activation.json"), + &pretty_json(&activation)?, + )?; + write_text( + &root.join("activate.ps1"), + &render_powershell_activation(&plugin_paths, &library_paths, &python_paths, loader_env), + )?; + write_text( + &root.join("activate.sh"), + &render_bash_activation( + &plugin_paths, + &library_paths, + &python_paths, + loader_env, + target_os, + ), + )?; + write_text( + &root.join("openstrata_activate.py"), + &render_python_activation(&plugin_paths, &library_paths, &python_paths), + ) +} + +fn extend_product_activation_paths( + output: &mut Vec, + member: &str, + relative_paths: &[String], +) -> Result<()> { + for relative in relative_paths { + // Validate the member-authored relative path independently before + // prefixing it into the aggregate installation layout. + safe_product_join(Utf8Path::new("."), relative, "member activation path")?; + let aggregate = format!("bundles/{member}/{relative}"); + if !output.contains(&aggregate) { + output.push(aggregate); + } + } + Ok(()) +} + +fn parse_product_os(value: &str) -> Result { + match value { + "linux" => Ok(Os::Linux), + "macos" => Ok(Os::Macos), + "windows" => Ok(Os::Windows), + other => Err(Error::validation(format!( + "unsupported product activation target_os '{other}'" + ))), + } +} + +fn report_product_verification(product: &VerifiedPluginProduct, fmt: Format) { + if fmt.is_json() { + output::success(&serde_json::json!({ + "verified": true, + "name": product.contract.name, + "version": product.contract.version, + "target": product.contract.target, + "archive": product.source.archive, + "archive_digest": product.source.digest, + "archive_size": product.source.size, + "members": product.contract.install.order, + })); + } else { + println!( + "Verified plugin product {} {} for {}", + product.contract.name, product.contract.version, product.contract.target + ); + println!(" digest: {}", product.source.digest); + println!(" archive: {}", product.source.archive); + println!( + " members: {} ({})", + product.contract.members.len(), + product.contract.install.order.join(", ") + ); + } +} + /// The hex digest without the `sha256:` scheme prefix (the `sha256sum -c` /// on-disk format). fn bare_sha256(digest: &str) -> &str { @@ -5720,6 +6676,84 @@ mod tests { }) } + fn product_member() -> PluginProductMember { + PluginProductMember { + id: "toy".into(), + position: 0, + name: "toy".into(), + version: "0.1.0".into(), + kind: "usd-fileformat".into(), + archive: "members/toy/toy-0.1.0.tar.zst".into(), + archive_digest: format!("sha256:{}", "ab".repeat(32)), + archive_size: 1, + manifest: "members/toy/manifest.json".into(), + checksums: "members/toy/SHA256SUMS".into(), + evidence: vec!["members/toy/sbom.spdx.json".into()], + debug: RequiredProductDebug(None), + dependencies: serde_json::Value::Null, + } + } + + #[test] + fn explicit_product_archive_ignores_an_unrelated_sibling_manifest() { + let root = unique_tmp("explicit-product-archive"); + std::fs::create_dir_all(root.as_std_path()).unwrap(); + let archive = root.join("downloaded-product.tar.zst"); + std::fs::write(archive.as_std_path(), b"standalone product bytes").unwrap(); + write_test_file( + &root.join("manifest.json"), + &pretty_json(&publishable_manifest()).unwrap(), + ); + + let source = resolve_product_archive(archive.as_str(), None) + .expect("an explicit archive must not be shadowed by an unrelated manifest"); + let (digest, size) = digest_file(&archive).unwrap(); + assert_eq!(source.archive, archive); + assert_eq!(source.digest, digest); + assert_eq!(source.size, size); + + std::fs::remove_dir_all(root.as_std_path()).ok(); + } + + #[test] + fn product_member_target_must_match_the_product_target() { + let mut manifest = publishable_manifest(); + manifest["archive"] = "toy-0.1.0.tar.zst".into(); + manifest["target"] = "cy2026-linux-x86_64-glibc228-py313-usd".into(); + + let error = verify_product_member_manifest( + "cy2026-windows-x86_64-msvc143-py313-usd", + &product_member(), + &manifest, + ) + .unwrap_err(); + + assert!(error.to_string().contains("manifest target"), "{error}"); + assert!( + error + .to_string() + .contains("cy2026-windows-x86_64-msvc143-py313-usd"), + "{error}" + ); + } + + #[test] + fn legacy_product_install_fields_remain_optional_in_v1alpha1() { + let schema: serde_json::Value = serde_json::from_str(include_str!( + "../../../../schemas/plugin-product.schema.json" + )) + .unwrap(); + let required = schema["properties"]["install"]["required"] + .as_array() + .unwrap(); + + assert!(required.iter().any(|value| value == "layout")); + assert!(required.iter().any(|value| value == "order")); + assert!(required.iter().any(|value| value == "contract")); + assert!(!required.iter().any(|value| value == "destination")); + assert!(!required.iter().any(|value| value == "activation")); + } + #[test] fn debug_symbol_files_are_classified_for_the_lean_split() { // Split out of the lean main package into the sibling `*-debug` archive. diff --git a/crates/ost-cli/src/commands/renderer.rs b/crates/ost-cli/src/commands/renderer.rs index 504567f..27f15b0 100644 --- a/crates/ost-cli/src/commands/renderer.rs +++ b/crates/ost-cli/src/commands/renderer.rs @@ -107,6 +107,11 @@ pub enum RendererCmd { #[arg(long)] profile: Option, + /// Resolve the intent, runtime profile, adapter, and scene capabilities, + /// then stop before configuring or building. + #[arg(long)] + preflight: bool, + /// Arguments passed to the viewport executable after `--`, e.g. /// `ost renderer viewport -- --frames 8 --hidden`. #[arg(last = true)] @@ -258,6 +263,7 @@ pub fn run(cmd: RendererCmd, fmt: Format) -> Result<()> { intent, target, profile, + preflight, args, } => viewport( ViewportArgs { @@ -266,6 +272,7 @@ pub fn run(cmd: RendererCmd, fmt: Format) -> Result<()> { intent, target, profile, + preflight, args, }, fmt, @@ -353,6 +360,7 @@ struct ViewportArgs { intent: Option, target: Option, profile: Option, + preflight: bool, args: Vec, } @@ -1093,9 +1101,9 @@ fn viewport(args: ViewportArgs, fmt: Format) -> Result<()> { })? .clone(); - // One ordinary managed build with the viewport intent. The standalone - // viewport renders the project bootstrap scene, so the project's - // host-neutral profile suffices and no runtime fingerprint is recorded. + // One ordinary managed build with the viewport intent. A host-neutral + // viewport may use `core`; a scene workflow must select a profile carrying + // the capabilities implied by its passthrough arguments. let (target, _) = build_target(&platform, &profile)?; let mut intent = match args.intent.as_deref() { Some(_) => build::resolve_declared_intent(&root, args.intent.as_deref())?, @@ -1109,6 +1117,40 @@ fn viewport(args: ViewportArgs, fmt: Format) -> Result<()> { "OST_RENDERER_ADAPTERS", CMakeCacheEntry::string("viewport"), )?; + let preflight = viewport_capability_preflight( + &adapter, + &target.id(), + &platform, + &profile, + &intent, + &args.args, + &target.capabilities, + )?; + if args.preflight { + if fmt.is_json() { + output::success(&serde_json::json!({ "preflight": preflight })); + } else { + println!("Renderer viewport preflight passed"); + println!(" target: {}", target.id()); + println!(" adapter: {adapter}"); + println!(" intent: {}", intent.name); + println!( + " capabilities: {}", + preflight["capabilities"]["applied"] + .as_array() + .map(|items| { + items + .iter() + .filter_map(Value::as_str) + .collect::>() + .join(", ") + }) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| "none requested".into()) + ); + } + return Ok(()); + } let build_dir = root.join(build::build_dir_for_intent(&target.id(), &intent)); let renderer_reports_before = snapshot_managed_renderer_reports(&root, &build_dir)?; if !fmt.is_json() { @@ -1206,6 +1248,7 @@ fn viewport(args: ViewportArgs, fmt: Format) -> Result<()> { "config": args.config, "build_dir": build_dir, "intent": intent, + "preflight": preflight, "args": args.args, "started_unix": viewport_started_unix, "completed_unix": completed_unix, @@ -1250,6 +1293,83 @@ fn viewport(args: ViewportArgs, fmt: Format) -> Result<()> { } } +fn viewport_capability_preflight( + adapter: &str, + target_id: &str, + platform: &str, + profile: &str, + intent: &BuildIntent, + args: &[String], + available: &[String], +) -> Result { + let usd_scene = args.iter().any(|arg| { + let lower = arg.to_ascii_lowercase(); + matches!(lower.as_str(), "--usd" | "--scene") + || lower.starts_with("--usd=") + || lower.starts_with("--scene=") + || [".usd", ".usda", ".usdc", ".usdz"] + .iter() + .any(|extension| lower.ends_with(extension)) + }); + let requested = if usd_scene { + vec!["usd-stage-read"] + } else { + Vec::new() + }; + let missing = requested + .iter() + .filter(|capability| !available.iter().any(|value| value == **capability)) + .copied() + .collect::>(); + if !missing.is_empty() { + return Err(Error::coded( + "RENDERER_VIEWPORT_CAPABILITY_MISSING", + Category::Precondition, + format!( + "viewport scene workflow requests {}, but profile '{profile}' provides [{}]", + missing.join(", "), + available.join(", ") + ), + ) + .with_hint(format!( + "select a profile that provides {} (for the built-in catalog, pass `--profile usd`)", + missing.join(", ") + )) + .with_phase("renderer-viewport-preflight")); + } + let applied = requested.clone(); + let unrequested = available + .iter() + .filter(|capability| !requested.iter().any(|value| value == capability)) + .cloned() + .collect::>(); + let skipped = if usd_scene { + Vec::new() + } else { + vec![serde_json::json!({ + "capability": "usd-stage-read", + "reason": "no USD scene argument was requested", + })] + }; + Ok(serde_json::json!({ + "schema": "openstrata.renderer-preflight/v1alpha1", + "passed": true, + "workflow": if usd_scene { "usd-scene" } else { "standalone" }, + "adapter": adapter, + "target": target_id, + "platform": platform, + "profile": profile, + "intent": intent, + "args": args, + "capabilities": { + "requested": requested, + "applied": applied, + "skipped": skipped, + "unrequested": unrequested, + }, + })) +} + fn viewport_session_outcome(exit_code: Option) -> SessionOutcome { // 77 is the viewport's capability-skip contract: the invocation concluded // normally and established that presentation is unavailable. It must not @@ -2009,6 +2129,67 @@ mod tests { ); } + #[test] + fn viewport_preflight_requires_usd_capability_before_building() { + let error = viewport_capability_preflight( + "merlinViewport", + "cy2026-windows-core", + "cy2026", + "core", + &BuildIntent::default(), + &["--usd".into(), "scene.usd".into()], + &[], + ) + .unwrap_err(); + assert_eq!(error.code(), "RENDERER_VIEWPORT_CAPABILITY_MISSING"); + assert_eq!(error.phase(), Some("renderer-viewport-preflight")); + assert!(error.to_string().contains("profile 'core'")); + } + + #[test] + fn viewport_preflight_records_requested_applied_and_skipped_capabilities() { + let usd = viewport_capability_preflight( + "merlinViewport", + "cy2026-windows-usd", + "cy2026", + "usd", + &BuildIntent::default(), + &["--usd=scene.usda".into()], + &["usd-stage-read".into(), "usd-python".into()], + ) + .unwrap(); + assert_eq!(usd["workflow"], "usd-scene"); + assert_eq!( + usd["capabilities"]["requested"], + serde_json::json!(["usd-stage-read"]) + ); + assert_eq!( + usd["capabilities"]["applied"], + serde_json::json!(["usd-stage-read"]) + ); + assert_eq!( + usd["capabilities"]["unrequested"], + serde_json::json!(["usd-python"]) + ); + assert_eq!(usd["capabilities"]["skipped"], serde_json::json!([])); + + let standalone = viewport_capability_preflight( + "merlinViewport", + "cy2026-windows-core", + "cy2026", + "core", + &BuildIntent::default(), + &["--frames".into(), "1".into()], + &[], + ) + .unwrap(); + assert_eq!(standalone["workflow"], "standalone"); + assert_eq!( + standalone["capabilities"]["skipped"][0]["capability"], + "usd-stage-read" + ); + } + #[test] fn managed_stamp_owns_only_reports_changed_by_the_invocation() { let root = temp_dir("managed-stamp"); diff --git a/crates/ost-cli/src/progress.rs b/crates/ost-cli/src/progress.rs index 16c0d13..c6c430d 100644 --- a/crates/ost-cli/src/progress.rs +++ b/crates/ost-cli/src/progress.rs @@ -475,9 +475,14 @@ impl Reporter { drop(out); drop(err); let tail = output_tail(&tail); + let log = self + .log + .as_ref() + .map(|path| path.display().to_string()) + .unwrap_or_else(|| "".into()); self.close_current(Outcome::Failed(None)); return Err(Error::external_tool(format!( - "command timed out after {}s: {} (pid {pid}, cwd '{cwd}', cleanup: {cleanup}, last output: {})", + "command timed out after {}s: {} (phase '{phase}', pid {pid}, cwd '{cwd}', log '{log}', cleanup: {cleanup}, last output: {})", timeout.unwrap_or_default().as_secs(), render_command(program, args), if tail.is_empty() { "".into() } else { one_line(&tail) } @@ -829,13 +834,28 @@ mod tests { }; let mut reporter = Reporter::new(ProgressMode::Plain, 1, true); reporter.phase("Timeout fixture"); + let log = camino::Utf8PathBuf::from_path_buf(std::env::temp_dir()) + .unwrap() + .join(format!( + "ost-timeout-fixture-{}-{}.log", + std::process::id(), + now_unix() + )); + reporter.set_log(&log); let started = Instant::now(); let error = reporter .run_status(&program, &args, &cwd, &[], Some(Duration::from_millis(100))) .unwrap_err(); assert_eq!(error.category(), ost_core::Category::ExternalTool); assert_eq!(error.phase(), Some("timeout-fixture")); - assert!(error.to_string().contains("timed out")); + let message = error.to_string(); + assert!(message.contains("timed out")); + assert!(message.contains("phase 'timeout-fixture'"), "{message}"); + assert!(message.contains("pid "), "{message}"); + assert!(message.contains("log '"), "{message}"); + assert!(message.contains("cleanup:"), "{message}"); + assert!(message.contains("last output:"), "{message}"); assert!(started.elapsed() < Duration::from_secs(5)); + let _ = std::fs::remove_file(log); } } diff --git a/crates/ost-cli/tests/lifecycle.rs b/crates/ost-cli/tests/lifecycle.rs index 9bcd8f7..f7c3681 100644 --- a/crates/ost-cli/tests/lifecycle.rs +++ b/crates/ost-cli/tests/lifecycle.rs @@ -1627,6 +1627,11 @@ fn build_failure_names_the_phase_and_log() { text.contains("build.log"), "should point at the build log:\n{text}" ); + let lease_path = single_target_dir(&sb.work).join(TARGET_LEASE_FILE); + assert!( + lease_is_unowned(&lease_path), + "a handled build failure must release its target lease" + ); } #[test] @@ -2477,6 +2482,26 @@ fn workspace_packaging_records_the_bundle_closure_in_dependency_order() { .as_str() .unwrap() .ends_with("-plugin-product.tar.zst")); + let first_product_digest = value["data"]["product"]["archive_digest"] + .as_str() + .unwrap() + .to_string(); + + // The aggregate embeds member manifests and evidence, not only their stable + // archives. Those sidecars used to carry a fresh wall-clock created_unix, + // so product bytes changed while every member digest stayed identical. + std::thread::sleep(std::time::Duration::from_secs(1)); + let repeated = sb.ost(&["--json", "plugin", "package", "--workspace", "--product"]); + assert!( + repeated.status.success(), + "repeated workspace package failed:\n{}", + out_text(&repeated) + ); + let repeated: serde_json::Value = serde_json::from_slice(&repeated.stdout).unwrap(); + assert_eq!( + repeated["data"]["product"]["archive_digest"], first_product_digest, + "an unchanged aggregate product must be byte-reproducible" + ); // The consumer's artifact carries the bundle it needs, so a consumer can // detect a missing provider from the manifest instead of at load time. @@ -2613,6 +2638,67 @@ fn workspace_packaging_records_the_bundle_closure_in_dependency_order() { .any(|path| path.starts_with("members/consumer/") && path.ends_with("manifest.json"))); let product_dist = product_manifest.parent().unwrap().to_str().unwrap(); + let verified = sb.ost(&["--json", "plugin", "product", "verify", product_dist]); + assert!( + verified.status.success(), + "product verify must cover the outer archive and every member:\n{}", + out_text(&verified) + ); + let verified: serde_json::Value = serde_json::from_slice(&verified.stdout).unwrap(); + assert_eq!(verified["data"]["verified"], true); + assert_eq!(verified["data"]["archive_digest"], first_product_digest); + assert_eq!( + verified["data"]["members"], + serde_json::json!(["schema", "consumer"]) + ); + + let install_prefix = sb.work_file("installed-product"); + let install_prefix_arg = install_prefix.to_str().unwrap(); + let installed = sb.ost(&[ + "--json", + "plugin", + "product", + "install", + product_dist, + "--prefix", + install_prefix_arg, + "--expect-digest", + &first_product_digest, + ]); + assert!( + installed.status.success(), + "product install failed:\n{}", + out_text(&installed) + ); + for path in [ + "bundles/schema/openstrata.plugin.yaml", + "bundles/consumer/openstrata.plugin.yaml", + "openstrata.activation.json", + "activate.ps1", + "activate.sh", + "openstrata_activate.py", + "openstrata.product-install.json", + ] { + assert!( + install_prefix.join(path).is_file(), + "product install omitted {path}" + ); + } + let aggregate_activation: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(install_prefix.join("openstrata.activation.json")).unwrap(), + ) + .unwrap(); + assert!(aggregate_activation["plugin_paths"] + .as_array() + .unwrap() + .iter() + .any(|path| path == "bundles/schema/plugin/resources/schema")); + assert!(aggregate_activation["library_paths"] + .as_array() + .unwrap() + .iter() + .any(|path| path == "bundles/consumer/third_party/bin")); + let imported = sb.ost(&["--json", "artifact", "import", product_dist]); assert!( imported.status.success(), diff --git a/docs/guides/adopt-a-renderer-project.md b/docs/guides/adopt-a-renderer-project.md index b9d2565..b59161f 100644 --- a/docs/guides/adopt-a-renderer-project.md +++ b/docs/guides/adopt-a-renderer-project.md @@ -76,6 +76,19 @@ ost renderer view scene.usda --profile usd # managed usdview session ost renderer viewport -- --frames 8 --hidden # standalone native viewport ``` +Before paying the configure/build cost, resolve the named intent and +scene/runtime capabilities: + +```sh +ost renderer viewport --preflight --intent viewport-usd --profile usd -- \ + --usd path/to/scene.usd --frames 1 --hidden +``` + +The JSON form publishes normalized `requested`, `applied`, `skipped`, and +`unrequested` capability evidence. A USD scene workflow fails here—before the +build tree is touched—unless the selected profile provides `usd-stage-read`. +The same preflight object is retained in the durable viewport launch record. + `renderer view` defaults to automatic camera selection and classifies optional host warnings separately from real plugin-discovery, renderer-selection, or first-frame failures. diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 8e83ffb..7de3195 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -660,6 +660,7 @@ Scaffold, inspect, build, and diagnose OpenUSD plugin bundles - [`ost plugin inspect`](#ost-plugin-inspect) — Report a bundle's Level 0 structure - [`ost plugin new`](#ost-plugin-new) — Scaffold a new plugin bundle from a template - [`ost plugin package`](#ost-plugin-package) — Pack a built plugin bundle into a target-specific tar.zst artifact +- [`ost plugin product`](#ost-plugin-product) — Verify or install an aggregate plugin product artifact - [`ost plugin publish`](#ost-plugin-publish) — Publish a packaged plugin artifact into the local registry (by digest) - [`ost plugin run`](#ost-plugin-run) — Launch a command inside the plugin's runtime session (needs a real runtime) - [`ost plugin schema`](#ost-plugin-schema) — Manage a bundle's co-located USD schema @@ -777,6 +778,54 @@ Pack a built plugin bundle into a target-specific tar.zst artifact | `--with-debug` | Ship debug symbols (`.pdb`, `.dwo`) *inside* the main package instead of the default lean package. By default the main archive is lean and any debug symbols are split into a sibling `*-debug` package | | `--workspace` | Package every discovered bundle, in dependency order, using the same validated graph `plugin test --workspace` checks | +#### `ost plugin product` + +Verify or install an aggregate plugin product artifact + +**Usage:** `ost plugin product ` + +**Subcommands:** + +- [`ost plugin product install`](#ost-plugin-product-install) — Verify and install every member in dependency order +- [`ost plugin product verify`](#ost-plugin-product-verify) — Verify the product archive and every member archive/checksum + +##### `ost plugin product install` + +Verify and install every member in dependency order + +**Usage:** `ost plugin product install [OPTIONS] ` + +**Arguments:** + +| Argument | Required | Description | +| --- | --- | --- | +| `` | yes | Product dist directory, manifest.json, or product .tar.zst archive | + +**Options:** + +| Option | Description | +| --- | --- | +| `--expect-digest ` | Require the outer product archive to have this full sha256 digest | +| `--prefix ` | New installation root. The command refuses to overwrite it | + +##### `ost plugin product verify` + +Verify the product archive and every member archive/checksum + +**Usage:** `ost plugin product verify [OPTIONS] ` + +**Arguments:** + +| Argument | Required | Description | +| --- | --- | --- | +| `` | yes | Product dist directory, manifest.json, or product .tar.zst archive | + +**Options:** + +| Option | Description | +| --- | --- | +| `--expect-digest ` | Require the outer product archive to have this full sha256 digest | + #### `ost plugin publish` Publish a packaged plugin artifact into the local registry (by digest) @@ -1079,6 +1128,7 @@ Build and launch the standalone native viewport adapter | `--config ` | CMake configuration to build | | `--generator ` | CMake generator for the managed build. Ninja remains the default | | `--intent ` | Project-declared build intent to combine with the viewport workflow | +| `--preflight` | Resolve the intent, runtime profile, adapter, and scene capabilities, then stop before configuring or building | | `--profile ` | Profile for the managed build. Defaults to the project's profile; the standalone viewport needs no OpenUSD runtime | | `--target ` | Platform target, e.g. `cy2026`. Defaults to the project's platform | diff --git a/docs/reference/plugin-workspace.md b/docs/reference/plugin-workspace.md index d6c9611..0b56bd3 100644 --- a/docs/reference/plugin-workspace.md +++ b/docs/reference/plugin-workspace.md @@ -156,6 +156,24 @@ retains the handles for the life of the process. This is the supported bridge from `requires.runtime_libs` to non-`ost` consumers; parsing the plugin YAML and guessing loader behavior is not. +For example, after the OpenUSD host itself is active: + +```powershell +# OpenUSD command-line host +. .\activate.ps1 +usdcat tests/fixtures/minimal.vrm + +# Python 3.8+ on Windows: retain package DLL-directory handles before pxr loads. +python -c "import openstrata_activate; from pxr import Usd; assert Usd.Stage.Open('tests/fixtures/minimal.vrm')" +``` + +The OpenUSD installation remains responsible for activating its own +`bin`/`lib`/Python directories; the package entrypoint adds and retains the +package's staged dependency directories. A vendor/runtime Python launcher +normally provides the first half. When embedding OpenUSD into a stock Windows +Python process, register the host's DLL directories with +`os.add_dll_directory()` before importing `openstrata_activate`. + Package-origin verification carries its oracle too. For every declared `tests.roundtrip` fixture that has an adjacent `.golden.usda`, `ost plugin package` stages both files and emits @@ -204,11 +222,34 @@ members//provenance.intoto.jsonl # when the member has provenance member's archive digest, manifest, checksums, evidence, optional debug archive, and dependency closure. The product is built from the exact per-bundle package outputs—not from sibling source paths—so every member remains independently -verifiable after a single product download. Verify the product `SHA256SUMS`, -then each member `SHA256SUMS`, and extract members in `install.order`. The -aggregate itself has a producer manifest, SBOM, digest, and registry kind -`product`, so `ost artifact import` / `verify` / transport treat it as a -first-class artifact. +verifiable after a single product download. Product identity is the enclosing +project's `project.name`, effective project version, and target; its archive is +named `---plugin-product.tar.zst`. Member bundle names and +versions stay independent and are pinned by the product contract. + +Use the product commands instead of unpacking members manually: + +```sh +# A producer dist directory or its manifest.json carries the expected digest. +ost plugin product verify dist/products/// +ost plugin product install dist/products/// \ + --prefix ./installed-product + +# A standalone downloaded archive can be pinned explicitly. +ost plugin product verify product.tar.zst --expect-digest sha256:<64-hex> +ost plugin product install product.tar.zst --expect-digest sha256:<64-hex> \ + --prefix ./installed-product +``` + +Verification covers the product digest, strict contract and order, member +archive digests/sizes, each member `SHA256SUMS`, evidence presence, extracted +file inventory, and bundle validity. Installation refuses to replace an +existing prefix, expands members under `bundles//` in dependency order, and +emits aggregate `activate.ps1`, `activate.sh`, +`openstrata.activation.json`, and `openstrata_activate.py` entrypoints. The +aggregate itself also has a producer manifest, SBOM, digest, and registry kind +`product`, so `ost artifact import` / `verify` / transport remain available for +digest-addressed registry workflows. A `requires.bundles` provider travels as **both halves**. Its link half is staged under `runtime/bundles//lib`, beside the provider-relative path its diff --git a/docs/reference/support-matrix.md b/docs/reference/support-matrix.md index 25b9103..3562ef5 100644 --- a/docs/reference/support-matrix.md +++ b/docs/reference/support-matrix.md @@ -36,4 +36,4 @@ Per-feature, per-platform support levels. Sourced from [`support/platforms.toml` [^4]: A Unix concept; Windows has no execute bit to preserve. [^5]: Linux SDK soname chains; not part of the Windows packaging model. [^6]: A Linux-only concept (ELF scan of the runtime's binaries). -[^7]: Planned (Phase 10 / v0.19.0); not yet implemented on any platform. +[^7]: Planned (Phase 10 / v0.21.0); not yet implemented on any platform. diff --git a/docs/roadmap/backlog.md b/docs/roadmap/backlog.md index f25dad6..2e62d31 100644 --- a/docs/roadmap/backlog.md +++ b/docs/roadmap/backlog.md @@ -9,9 +9,9 @@ Legend: ⬜ not started ## Milestone ladder (beyond next) The v0.19.0 composition and reach milestone is shipped in -[v0.19.0](../releases/v0.19.0.md). Its remaining clean-machine acceptance -dogfoods stay visible in [current.md](current.md); the next scheduled milestone -is v0.20.0. +[v0.19.0](../releases/v0.19.0.md). The active v0.20.0 dogfood-closure and +renderer-workflow milestone is in [current.md](current.md). DCC host integration +follows it in v0.21.0. The Formation scope below is **Half B** of v0.19.0, narrowed to `resolve|inspect|run|lock`. It is gated on Half A (artifact closure, staged-byte @@ -48,10 +48,9 @@ milestone, Formation ships in v0.20.0 and DCC host integration moves to v0.21.0. namespace / overlayfs sandboxing, detached session management, general-purpose package solving, automatic Formation-bundle publication, and implicit download from untrusted sources. -- ⬜ **v0.20.0 - DCC host integration (Phase 10).** Deferred from the v0.19.0 - slot by the inserted Formation milestone (and originally from v0.18.0 by the - v0.17.0 dogfooding findings). Moves again to v0.21.0 if v0.19.0 Half A pushes - Formation into the v0.20.0 slot. Extends OpenStrata beyond runtime-native OpenUSD +- ⬜ **v0.21.0 - DCC host integration (Phase 10).** Deferred from v0.20.0 by + the v0.19.0 package/release and renderer dogfooding findings. Extends + OpenStrata beyond runtime-native OpenUSD applications without redistributing DCC SDKs or inventing one false cross-DCC API: an `ost-host` model with a versioned host record (product, version, install root, executable/API locations, Python ABI, platform fingerprint, diff --git a/docs/roadmap/current.md b/docs/roadmap/current.md index ba6a8c8..d1e09fe 100644 --- a/docs/roadmap/current.md +++ b/docs/roadmap/current.md @@ -3,6 +3,115 @@ The next milestone and active carry-over work. Shipped detail is in [releases/](../releases/) and the [delivery history](../reports/delivery-history.md). +## v0.20.0 - dogfood closure and renderer workflow + +**Status:** 🚧 in progress from 2026-07-23 · **Depends on:** v0.19.0 reachable +packages, aggregate products, managed producer sessions, and Formation. + +The v0.20.0 scope is driven by the first v0.19.0 release-lane and renderer +dogfoods: + +- usd-vrm-plugins + ([adoption asks](https://github.com/animu-sphere/usd-vrm-plugins/blob/main/docs/reports/ost/26-2026-07-23-v0.19.0-v0.20.0-asks.md), + [aggregate reproducibility](https://github.com/animu-sphere/usd-vrm-plugins/blob/main/docs/reports/ost/27-2026-07-23-v0.19.0-aggregate-product-reproducibility.md)); +- hdMerlin + ([renderer asks](https://github.com/animu-sphere/hydra-merlin/blob/main/docs/reports/ost/09-2026-07-23-v0.20.0-asks.md), + findings OST20-RND-001..007). + +This is a corrective/reach milestone. **DCC host integration does not ship in +v0.20.0; it moves to v0.21.0.** The work below remains runtime-, package-, and +renderer-native and introduces no DCC adapter or host-discovery surface. + +### P0 - independently installable package closure + +A package with `requires.bundles` must carry the provider's USD registration +tree and link/runtime tree and activate both without a sibling source package. +The acceptance command remains: + +```console +ost plugin test plugins/usdVrmFileFormat --from-package --up-to 4 +``` + +It must pass L2 discovery, L3 `usdcat`, and L4 `Usd.Stage.Open()` with no +separate `--with vrmSchema`. The v0.20.0 intake re-ran this against the current +usd-vrm-plugins main package and a real `cy2026/windows/usd` runtime: all three +levels pass and the archive contains the provider `plugInfo.json`, +`generatedSchema.usda`, and library. v0.20.0 keeps this as an explicit regression +gate so a stale or partially re-packaged v0.2.0-shaped artifact cannot re-open +the defect. + +### P0 - byte-reproducible aggregate products + +Two unchanged `plugin package --workspace --product` invocations must produce +the same aggregate digest on Windows, Linux, and macOS. v0.19.0 normalized tar +metadata but embedded freshly timestamped member `manifest.json` sidecars in +the product; member archive digests therefore stayed stable while the aggregate +changed. + +**Implemented on the v0.20.0 branch:** plugin and product producer manifests use +the same reproducible timestamp contract as archive entries +(`SOURCE_DATE_EPOCH`, otherwise epoch 0). The lifecycle waits across a wall-clock +tick, packages again, and requires the product digest to remain identical. +Member order, manifests, checksums, evidence, activation contracts, and paths +remain part of the aggregate bytes. + +### P1 - first-class product verification and installation + +The aggregate is one release artifact, not merely a tar file containing a +manual install recipe. + +**Implemented on the v0.20.0 branch:** `ost plugin product verify` checks the +outer digest (optionally pinned by `--expect-digest`), strict product contract, +member identities/order, member archive digests and sizes, every member +`SHA256SUMS`, evidence presence, extracted file inventory, and bundle validity. +`ost plugin product install --prefix ` runs that verification, extracts +members in dependency order under `bundles//`, refuses to overwrite an +existing prefix, and emits aggregate PowerShell, Bash, JSON, and Python +activation entrypoints. Product identity is the enclosing project's +`project.name` + effective version + target; member identities and versions stay +independent and pinned in `openstrata.product.json`. + +The package-level non-`ost` activation contract remains load-bearing. On +Windows, `activate.ps1` is sufficient for OpenUSD executables, while Python 3.8+ +consumers import `openstrata_activate` before `pxr` so the package's staged DLL +directories are registered and retained. A real extracted usdVrmFileFormat +package has been exercised through both plain `usdcat` and a host Python/OpenUSD +process. + +### P1 - actionable managed timeout recovery (OST20-RND-001) + +Configure/build timeout errors must name the active phase, child PID, command, +cwd, retained log, output tail, and process-tree cleanup result. A timeout OST +handles must reap descendants and clear the target lease before returning; +only an actually interrupted/killed OST process leaves takeover evidence. + +**Implemented on the v0.20.0 branch:** the shared process runner reports that +complete diagnostic and verifies child termination, and managed build failure +paths release their lease after renderer failure evidence is stamped. + +### P1 - renderer intent/capability preflight (OST20-RND-004/006) + +`ost renderer viewport --preflight` resolves the adapter, named build intent, +target/profile, passthrough scene workflow, and normalized +requested/applied/skipped/unrequested capability evidence without configuring +or building. Passing `--usd`, `--scene`, or a USD-family scene path requires +`usd-stage-read`; an incompatible profile fails in the preflight phase with an +exact `--profile usd` correction. The same preflight record is embedded in a +successful durable viewport launch record. + +### P1/P2 - remaining renderer evidence closure + +- **OST20-RND-002:** bind the producer session and completion/report digests so + copied or stale reports cannot be upgraded into a managed PASS. Preserve + `external-unverified` attachment as the explicit external path. +- **OST20-RND-003/005:** dogfood one real USD viewport success and keep one JSON + envelope for success, build failure, presentation-unavailable, and child + failure. Persist launch/readiness, backend/device, output locations, and exit + state beside completion evidence. +- **OST20-RND-007:** add repeatable Ninja Multi-Config and Visual Studio + configuration-specific provenance fixtures. This is build provenance, not a + DCC-host implementation. + ## Shipped: v0.19.0 - composition and reach **Status:** ✅ shipped 2026-07-23 · **Depends on:** the v0.18.0 evidence-integrity, diff --git a/schemas/plugin-product.schema.json b/schemas/plugin-product.schema.json index 85c31a3..96cb32c 100644 --- a/schemas/plugin-product.schema.json +++ b/schemas/plugin-product.schema.json @@ -16,12 +16,14 @@ "required": ["layout", "order", "contract"], "properties": { "layout": { "const": "members//" }, + "destination": { "const": "bundles//" }, "order": { "type": "array", "minItems": 1, "uniqueItems": true, "items": { "type": "string", "minLength": 1 } }, + "activation": { "const": "openstrata.activation.json" }, "contract": { "type": "string", "minLength": 1 } } }, diff --git a/support/platforms.toml b/support/platforms.toml index 3bc3771..3340dc5 100644 --- a/support/platforms.toml +++ b/support/platforms.toml @@ -113,5 +113,5 @@ support = { linux_x86_64 = "stable", macos_arm64 = "unsupported", macos_x86_64 = [[features]] id = "dcc_integration" label = "DCC host integration" -note = "Planned (Phase 10 / v0.19.0); not yet implemented on any platform." +note = "Planned (Phase 10 / v0.21.0); not yet implemented on any platform." support = { linux_x86_64 = "unsupported", macos_arm64 = "unsupported", macos_x86_64 = "unsupported", windows_x86_64 = "unsupported" }