use std::fs;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::setup_core::error::{Error, ReasonCode, Result};
use crate::setup_core::lock;
pub const STATE_SCHEMA: u32 = 4;
pub const PROVENANCE_FIELDS: &[&str] = &[
"state_schema",
"protocol_version",
"provider_id",
"provider_version",
"provider_build_digest",
"provider_release_digest",
"harness_id",
"canonical_target",
"target_identity_digest",
"setup_stable_id",
"setup_version",
"setup_version_passport_digest",
"setup_definition_digest",
"component_refs",
"bundle_format",
"bundle_digest",
"artifact_digest",
"projection_profile_digest",
"provider_plan_digest",
"operation_id",
"target_precondition_digest",
"native_ownership",
"written_paths",
"backup_ref",
"previous_verified_identity",
"drift_state",
];
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DriftState {
Clean,
LocalDrift,
Unknown,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProviderState {
pub state_schema: u32,
pub protocol_version: u32,
pub provider_id: String,
pub provider_version: String,
pub provider_build_digest: String,
pub provider_release_digest: Option<String>,
pub harness_id: String,
pub canonical_target: String,
pub target_identity_digest: String,
pub setup_stable_id: Option<String>,
pub setup_version: Option<String>,
pub setup_version_passport_digest: Option<String>,
pub setup_definition_digest: Option<String>,
pub component_refs: Vec<String>,
pub bundle_format: Option<String>,
pub bundle_digest: Option<String>,
pub artifact_digest: Option<String>,
pub projection_profile_digest: Option<String>,
pub provider_plan_digest: Option<String>,
pub operation_id: String,
pub target_precondition_digest: String,
pub native_ownership: Vec<String>,
pub written_paths: Vec<String>,
pub backup_ref: Option<String>,
pub previous_verified_identity: Option<String>,
pub drift_state: DriftState,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum StateReading {
Absent,
Current(Box<ProviderState>),
ForeignSchema {
found_schema: u64,
},
}
impl ProviderState {
#[must_use]
pub fn path(target_root: &Path, state_file_name: &str) -> PathBuf {
target_root.join(state_file_name)
}
pub fn read(target_root: &Path, state_file_name: &str) -> Result<StateReading> {
let path = Self::path(target_root, state_file_name);
let bytes = match fs::read(&path) {
Ok(bytes) => bytes,
Err(source) if source.kind() == std::io::ErrorKind::NotFound => {
return Ok(StateReading::Absent);
}
Err(source) => {
return Err(Error::new(
ReasonCode::StateUnavailable,
format!("cannot read {}", path.display()),
)
.with_source(source));
}
};
let value: serde_json::Value = serde_json::from_slice(&bytes).map_err(|source| {
Error::new(
ReasonCode::StateUnavailable,
format!("{} does not parse as JSON", path.display()),
)
.with_source(source)
})?;
let found = value
.get("state_schema")
.and_then(serde_json::Value::as_u64);
match found {
Some(schema) if schema == u64::from(STATE_SCHEMA) => {
let state: Self = serde_json::from_value(value).map_err(|source| {
Error::new(
ReasonCode::StateUnavailable,
format!(
"{} is schema {STATE_SCHEMA} but does not match it",
path.display()
),
)
.with_source(source)
})?;
state.validate()?;
Ok(StateReading::Current(Box::new(state)))
}
Some(schema) => Ok(StateReading::ForeignSchema {
found_schema: schema,
}),
None => Err(Error::new(
ReasonCode::StateUnavailable,
format!("{} declares no state_schema", path.display()),
)),
}
}
pub(crate) fn validate(&self) -> Result<()> {
for member in self.written_paths.iter().chain(&self.native_ownership) {
crate::setup_core::native_snapshot::validate_path(member)?;
}
if let Some(reference) = &self.backup_ref {
crate::setup_core::backup::BackupRef::parse(reference)?;
}
Ok(())
}
pub fn write(&self, target_root: &Path, state_file_name: &str) -> Result<()> {
let value = serde_json::to_value(self).map_err(|source| {
Error::new(
ReasonCode::StateUnavailable,
"cannot encode the provider state",
)
.with_source(source)
})?;
let bytes = crate::setup_core::canonical::to_canonical_bytes(&value)?;
lock::atomic_write(&Self::path(target_root, state_file_name), &bytes)
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::panic)]
use super::*;
fn scratch(name: &str) -> PathBuf {
let base =
std::env::temp_dir().join(format!("setup-core-stamp-{name}-{}", std::process::id()));
let _ = fs::remove_dir_all(&base);
fs::create_dir_all(&base).unwrap();
base
}
fn sample() -> ProviderState {
ProviderState {
state_schema: STATE_SCHEMA,
protocol_version: 3,
provider_id: "claude-setup-system".to_owned(),
provider_version: "0.1.0".to_owned(),
provider_build_digest: "sha256:build".to_owned(),
provider_release_digest: None,
harness_id: "claude".to_owned(),
canonical_target: "/tmp/target".to_owned(),
target_identity_digest: "sha256:after".to_owned(),
setup_stable_id: Some("full-auto".to_owned()),
setup_version: Some("1".to_owned()),
setup_version_passport_digest: None,
setup_definition_digest: Some("sha256:definition".to_owned()),
component_refs: vec!["instruction:AGENTS.md".to_owned()],
bundle_format: Some("ai-stp-bundle/1".to_owned()),
bundle_digest: Some("sha256:bundle".to_owned()),
artifact_digest: Some("sha256:artifact".to_owned()),
projection_profile_digest: Some("sha256:profile".to_owned()),
provider_plan_digest: Some("sha256:plan".to_owned()),
operation_id: "op_test".to_owned(),
target_precondition_digest: "sha256:before".to_owned(),
native_ownership: vec!["settings.json".to_owned()],
written_paths: vec!["settings.json".to_owned()],
backup_ref: Some("slot-000000000001".to_owned()),
previous_verified_identity: None,
drift_state: DriftState::Clean,
}
}
#[test]
fn the_type_carries_exactly_the_manifest_provenance_fields() {
let manifest_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../provider-kit/v3/manifest.json");
let manifest: serde_json::Value =
serde_json::from_slice(&fs::read(&manifest_path).unwrap()).unwrap();
let declared: Vec<String> = manifest["provenance_fields"]
.as_array()
.unwrap()
.iter()
.map(|value| value.as_str().unwrap().to_owned())
.collect();
assert_eq!(declared, PROVENANCE_FIELDS);
let encoded = serde_json::to_value(sample()).unwrap();
let object = encoded.as_object().unwrap();
let mut present: Vec<&str> = object.keys().map(String::as_str).collect();
present.sort_unstable();
let mut expected: Vec<&str> = PROVENANCE_FIELDS.to_vec();
expected.sort_unstable();
assert_eq!(present, expected);
}
#[test]
fn an_absent_record_reads_as_absent() {
let root = scratch("absent");
assert_eq!(
ProviderState::read(&root, "STATE.json").unwrap(),
StateReading::Absent
);
}
#[test]
fn a_written_record_round_trips() {
let root = scratch("roundtrip");
let state = sample();
state.write(&root, "STATE.json").unwrap();
match ProviderState::read(&root, "STATE.json").unwrap() {
StateReading::Current(read) => assert_eq!(*read, state),
other => panic!("expected a current record, got {other:?}"),
}
}
#[test]
fn a_record_with_an_escaping_path_member_is_refused() {
let root = scratch("escaping-member");
for hostile in ["../outside", "/etc/passwd", "skills/./x", "a//b", ""] {
let mut value = serde_json::to_value(sample()).unwrap();
value["written_paths"] = serde_json::json!([hostile]);
fs::write(root.join("STATE.json"), serde_json::to_vec(&value).unwrap()).unwrap();
assert_eq!(
ProviderState::read(&root, "STATE.json")
.unwrap_err()
.reason(),
ReasonCode::IntegrityMismatch,
"accepted {hostile:?}"
);
}
let mut value = serde_json::to_value(sample()).unwrap();
value["native_ownership"] = serde_json::json!(["../outside"]);
fs::write(root.join("STATE.json"), serde_json::to_vec(&value).unwrap()).unwrap();
assert_eq!(
ProviderState::read(&root, "STATE.json")
.unwrap_err()
.reason(),
ReasonCode::IntegrityMismatch
);
}
#[test]
fn a_record_with_an_invalid_backup_reference_is_refused() {
let root = scratch("invalid-backup-ref");
let mut value = serde_json::to_value(sample()).unwrap();
value["backup_ref"] = serde_json::json!("slot-../escape");
fs::write(root.join("STATE.json"), serde_json::to_vec(&value).unwrap()).unwrap();
assert_eq!(
ProviderState::read(&root, "STATE.json")
.unwrap_err()
.reason(),
ReasonCode::IntegrityMismatch
);
}
#[test]
fn a_foreign_schema_is_reported_and_left_exactly_as_found() {
let root = scratch("foreign");
let path = root.join("STATE.json");
let original = br#"{"state_schema":99,"anything":"kept"}"#;
fs::write(&path, original).unwrap();
assert_eq!(
ProviderState::read(&root, "STATE.json").unwrap(),
StateReading::ForeignSchema { found_schema: 99 }
);
assert_eq!(fs::read(&path).unwrap(), original);
}
#[test]
fn a_record_without_a_schema_is_refused_rather_than_assumed_current() {
let root = scratch("schemaless");
fs::write(root.join("STATE.json"), br#"{"provider_id":"x"}"#).unwrap();
let error = ProviderState::read(&root, "STATE.json").unwrap_err();
assert_eq!(error.reason(), ReasonCode::StateUnavailable);
}
}