Purge leftover Rust implementation
Remove the legacy/ Rust tree (Cargo manifests, .rs sources, desktop entry), drop Rust-specific .gitignore blocks and README note, and strip now-dangling "ported from Rust" comments from the Go sources. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
-10
@@ -1,13 +1,3 @@
|
|||||||
# Generated by Cargo will have compiled files and executables
|
|
||||||
debug/
|
|
||||||
target/
|
|
||||||
|
|
||||||
# These are backup files generated by rustfmt
|
|
||||||
**/*.rs.bk
|
|
||||||
|
|
||||||
# MSVC Windows builds of rustc generate these, which store debugging information
|
|
||||||
*.pdb
|
|
||||||
|
|
||||||
# Local brainstorming companion artifacts
|
# Local brainstorming companion artifacts
|
||||||
.superpowers/
|
.superpowers/
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,7 @@
|
|||||||
A personal focus operating system: treat each work session as an explicit
|
A personal focus operating system: treat each work session as an explicit
|
||||||
commitment (next action, success condition, timebox), and make drift visible.
|
commitment (next action, success condition, timebox), and make drift visible.
|
||||||
|
|
||||||
This is the Go reimagining. The original Rust implementation is preserved under
|
See `docs/superpowers/specs/` for the design.
|
||||||
`legacy/` for reference. See `docs/superpowers/specs/` for the design.
|
|
||||||
|
|
||||||
## Run
|
## Run
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Package domain holds the core commitment types and validation, ported from
|
// Package domain holds the core commitment types and validation. These are
|
||||||
// the original Rust implementation. These are pure data types with no I/O.
|
// pure data types with no I/O.
|
||||||
package domain
|
package domain
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|||||||
@@ -30,8 +30,8 @@ type Source interface {
|
|||||||
Watch(ctx context.Context, onChange func(WindowSnapshot))
|
Watch(ctx context.Context, onChange func(WindowSnapshot))
|
||||||
}
|
}
|
||||||
|
|
||||||
// titleNoise matches the legacy clock/ratio/percent runs that pollute bucket
|
// titleNoise matches the clock/ratio/percent runs that pollute bucket
|
||||||
// keys (e.g. "1:23", "50.5%", "-3.0"). Ported verbatim from the Rust impl.
|
// keys (e.g. "1:23", "50.5%", "-3.0").
|
||||||
var titleNoise = regexp.MustCompile(`-?\d+([:.]\d+)+%?`)
|
var titleNoise = regexp.MustCompile(`-?\d+([:.]\d+)+%?`)
|
||||||
|
|
||||||
// extraSpace collapses the whitespace runs left behind by the scrubs below.
|
// extraSpace collapses the whitespace runs left behind by the scrubs below.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Package statemachine holds the pure runtime and commitment transition
|
// Package statemachine holds the pure runtime and commitment transition
|
||||||
// functions ported from the Rust implementation. No I/O, no shared state.
|
// functions. No I/O, no shared state.
|
||||||
package statemachine
|
package statemachine
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -9,8 +9,8 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// RuntimeAction enumerates runtime transitions. Activate's policy acceptance
|
// RuntimeAction enumerates runtime transitions. Activate's policy acceptance
|
||||||
// and admin-override exit targets from the Rust enum are flattened into
|
// and admin-override exit targets are flattened into distinct actions so the
|
||||||
// distinct actions so the action type stays a simple value.
|
// action type stays a simple value.
|
||||||
type RuntimeAction string
|
type RuntimeAction string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|||||||
Generated
-1102
File diff suppressed because it is too large
Load Diff
@@ -1,19 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "antidrift"
|
|
||||||
version = "0.1.0"
|
|
||||||
edition = "2021"
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
anyhow = "1.0.86"
|
|
||||||
fs2 = "0.4"
|
|
||||||
hex = "0.4.3"
|
|
||||||
ratatui = "0.27.0"
|
|
||||||
regex = "1.10.5"
|
|
||||||
shellexpand = "3.1.0"
|
|
||||||
serde = { version = "1.0", features = ["derive", "rc"] }
|
|
||||||
serde_json = "1.0"
|
|
||||||
sha2 = "0.10.8"
|
|
||||||
uuid = { version = "1", features = ["v7"] }
|
|
||||||
|
|
||||||
[target.'cfg(windows)'.dependencies]
|
|
||||||
winapi = { version = "0.3", features = ["winuser", "processthreadsapi", "windef"] }
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
[Desktop Entry]
|
|
||||||
Name=AntiDrift
|
|
||||||
Exec=antidrift
|
|
||||||
Terminal=false
|
|
||||||
Type=Application
|
|
||||||
StartupNotify=false
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
pub const APP_TITLE: &str = "AntiDrift";
|
|
||||||
pub const DEFAULT_DURATION: &str = "25";
|
|
||||||
pub const DURATION_TITLE: &str = "Duration";
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub const DEGRADED_EVIDENCE_TITLE: &str = "Evidence";
|
|
||||||
pub const ENTER_SUCCESS_CONDITION: &str = "Provide success condition! ";
|
|
||||||
pub const EVENT_LOG_FILE: &str = "~/.antidrift_events.jsonl";
|
|
||||||
pub const INTENTION_TITLE: &str = "Intention";
|
|
||||||
pub const PAUSED: &str = "paused";
|
|
||||||
pub const PREVIOUS_SESSIONS_TITLE: &str = "Previous Sessions";
|
|
||||||
pub const PROVIDE_INTENTION: &str = "Provide intention! ";
|
|
||||||
pub const PROVIDE_VALID_DURATION: &str = "Provide valid duration in minutes! ";
|
|
||||||
pub const RATE_TITLES: &str = "Press 1, 2, 3 to rate titles!";
|
|
||||||
pub const READY_TO_START: &str = "Ready to start next session.";
|
|
||||||
pub const SESSION_IN_PROGRESS: &str = "Session In-Progress";
|
|
||||||
pub const SESSION_PAUSED: &str = "Session is paused. Unpause with 'p'.";
|
|
||||||
pub const SESSION_STATS_TITLE: &str = "Session Stats";
|
|
||||||
pub const STATUS_FILE: &str = "~/.antidrift_status";
|
|
||||||
pub const HISTORY_FILE: &str = "~/.antidrift_history.jsonl";
|
|
||||||
pub const STATUS_TITLE: &str = "Status";
|
|
||||||
pub const STATUS_CONFIGURE: &str = "🔄 antidrift configure session";
|
|
||||||
pub const STATUS_LOCKED: &str = "🔒 antidrift locked";
|
|
||||||
pub const STATUS_PLANNING: &str = "🧭 antidrift planning";
|
|
||||||
pub const STATUS_PAUSED: &str = "⏸ antidrift paused";
|
|
||||||
pub const STATUS_RATE_SESSION: &str = "☑ rate antidrift session!";
|
|
||||||
pub const STATUS_QUIT: &str = "antidrift shutting down";
|
|
||||||
pub const STATUS_TRANSITION: &str = "↔ antidrift transition";
|
|
||||||
pub const SUCCESS_CONDITION_TITLE: &str = "Success Condition";
|
|
||||||
pub const TRANSITION_DURATION_TITLE: &str = "Transition Minutes";
|
|
||||||
pub const TRANSITION_REASON_TITLE: &str = "Transition Reason";
|
|
||||||
pub const TRANSITION_RETURN_TITLE: &str = "Return Target";
|
|
||||||
pub const VIOLATION_TITLE: &str = "Violation";
|
|
||||||
pub const VIOLATION_REASON_TITLE: &str = "Dismissal Reason";
|
|
||||||
pub const VIOLATION_STATUS: &str = "Unknown context detected. Enter a reason to continue.";
|
|
||||||
pub const PROVIDE_TRANSITION_REASON: &str = "Provide transition reason! ";
|
|
||||||
pub const PROVIDE_TRANSITION_RETURN: &str = "Provide return target! ";
|
|
||||||
@@ -1,156 +0,0 @@
|
|||||||
use crate::domain::AllowedContext;
|
|
||||||
|
|
||||||
pub fn domain_allowed(context: &AllowedContext, candidate: &str) -> bool {
|
|
||||||
let candidate = normalize_domain(candidate);
|
|
||||||
if candidate.is_empty() {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
context.domains.iter().any(|allowed| {
|
|
||||||
let allowed = normalize_domain(allowed);
|
|
||||||
!allowed.is_empty()
|
|
||||||
&& (candidate == allowed
|
|
||||||
|| candidate
|
|
||||||
.strip_suffix(&allowed)
|
|
||||||
.is_some_and(|prefix| prefix.ends_with('.')))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn window_class_allowed(context: &AllowedContext, candidate: &str) -> bool {
|
|
||||||
let candidate = normalize_casefolded(candidate);
|
|
||||||
if candidate.is_empty() {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
context
|
|
||||||
.window_classes
|
|
||||||
.iter()
|
|
||||||
.any(|allowed| normalize_casefolded(allowed) == candidate)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn window_title_allowed(context: &AllowedContext, candidate: &str) -> bool {
|
|
||||||
let candidate = normalize_casefolded(candidate);
|
|
||||||
if candidate.is_empty() {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
context.window_title_substrings.iter().any(|allowed| {
|
|
||||||
let allowed = normalize_casefolded(allowed);
|
|
||||||
!allowed.is_empty() && candidate.contains(&allowed)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn command_allowed(context: &AllowedContext, candidate: &str) -> bool {
|
|
||||||
let candidate = executable_basename(candidate);
|
|
||||||
if candidate.is_empty() {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
context
|
|
||||||
.commands
|
|
||||||
.iter()
|
|
||||||
.any(|allowed| executable_basename(allowed) == candidate)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn normalize_domain(value: &str) -> String {
|
|
||||||
value.trim().trim_end_matches('.').to_lowercase()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn normalize_casefolded(value: &str) -> String {
|
|
||||||
value.trim().to_lowercase()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn executable_basename(command: &str) -> String {
|
|
||||||
let executable = command.split_whitespace().next().unwrap_or_default();
|
|
||||||
executable
|
|
||||||
.trim()
|
|
||||||
.rsplit(['/', '\\'])
|
|
||||||
.next()
|
|
||||||
.unwrap_or_default()
|
|
||||||
.to_lowercase()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use crate::domain::AllowedContext;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn domains_match_exact_or_subdomain() {
|
|
||||||
let context = AllowedContext {
|
|
||||||
domains: vec!["github.com".to_string()],
|
|
||||||
..AllowedContext::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
assert!(domain_allowed(&context, "github.com"));
|
|
||||||
assert!(domain_allowed(&context, "docs.github.com"));
|
|
||||||
assert!(!domain_allowed(&context, "evilgithub.com"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn window_classes_match_case_insensitively_after_normalization() {
|
|
||||||
let context = AllowedContext {
|
|
||||||
window_classes: vec!["code".to_string()],
|
|
||||||
..AllowedContext::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
assert!(window_class_allowed(&context, "Code"));
|
|
||||||
assert!(!window_class_allowed(&context, "firefox"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn titles_match_by_substring() {
|
|
||||||
let context = AllowedContext {
|
|
||||||
window_title_substrings: vec!["antidrift".to_string()],
|
|
||||||
..AllowedContext::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
assert!(window_title_allowed(&context, "Commitment OS - AntiDrift"));
|
|
||||||
assert!(!window_title_allowed(&context, "random video"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn domains_ignore_whitespace_case_and_trailing_dots() {
|
|
||||||
let context = AllowedContext {
|
|
||||||
domains: vec![" GitHub.COM. ".to_string()],
|
|
||||||
..AllowedContext::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
assert!(domain_allowed(&context, " DOCS.GITHUB.COM. "));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn window_class_matching_trims_allowed_and_candidate_values() {
|
|
||||||
let context = AllowedContext {
|
|
||||||
window_classes: vec![" code ".to_string()],
|
|
||||||
..AllowedContext::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
assert!(window_class_allowed(&context, " Code "));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn title_matching_trims_allowed_substrings() {
|
|
||||||
let context = AllowedContext {
|
|
||||||
window_title_substrings: vec![" antidrift ".to_string()],
|
|
||||||
..AllowedContext::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
assert!(window_title_allowed(&context, "Commitment OS - AntiDrift"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn commands_match_executable_basename() {
|
|
||||||
let context = AllowedContext {
|
|
||||||
commands: vec!["cargo".to_string()],
|
|
||||||
..AllowedContext::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
assert!(command_allowed(
|
|
||||||
&context,
|
|
||||||
"/home/felixm/.cargo/bin/cargo test"
|
|
||||||
));
|
|
||||||
assert!(command_allowed(&context, "cargo"));
|
|
||||||
assert!(!command_allowed(&context, "/usr/bin/git status"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,515 +0,0 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use std::fmt;
|
|
||||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
pub const POLICY_SCHEMA_VERSION: u16 = 1;
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum CommitmentSource {
|
|
||||||
Manual,
|
|
||||||
Planner,
|
|
||||||
Recurring,
|
|
||||||
Recovery,
|
|
||||||
Template,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum CommitmentState {
|
|
||||||
Draft,
|
|
||||||
Active,
|
|
||||||
Paused,
|
|
||||||
Completed,
|
|
||||||
Abandoned,
|
|
||||||
Violated,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum RuntimeState {
|
|
||||||
Locked,
|
|
||||||
Planning,
|
|
||||||
Active,
|
|
||||||
Transition,
|
|
||||||
Review,
|
|
||||||
AdminOverride,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum EnforcementLevel {
|
|
||||||
Observe,
|
|
||||||
Warn,
|
|
||||||
Block,
|
|
||||||
Locked,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum EvidenceHealth {
|
|
||||||
Available,
|
|
||||||
Degraded(String),
|
|
||||||
Unavailable(String),
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
|
|
||||||
pub struct AllowedContext {
|
|
||||||
pub window_classes: Vec<String>,
|
|
||||||
pub window_title_substrings: Vec<String>,
|
|
||||||
pub domains: Vec<String>,
|
|
||||||
pub repos: Vec<String>,
|
|
||||||
pub commands: Vec<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
pub struct Commitment {
|
|
||||||
pub id: String,
|
|
||||||
pub created_at_unix_secs: u64,
|
|
||||||
pub source: CommitmentSource,
|
|
||||||
pub project_id: Option<String>,
|
|
||||||
pub template_id: Option<String>,
|
|
||||||
pub next_action: String,
|
|
||||||
pub success_condition: String,
|
|
||||||
pub timebox_secs: u64,
|
|
||||||
pub transition_policy_id: Option<String>,
|
|
||||||
pub state: CommitmentState,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
pub struct PolicySnapshot {
|
|
||||||
pub id: String,
|
|
||||||
pub commitment_id: String,
|
|
||||||
pub schema_version: u16,
|
|
||||||
pub created_at_unix_secs: u64,
|
|
||||||
pub runtime_state: RuntimeState,
|
|
||||||
pub enforcement_level: EnforcementLevel,
|
|
||||||
pub allowed_context: AllowedContext,
|
|
||||||
pub required_monitors: Vec<String>,
|
|
||||||
pub violation_actions: Vec<String>,
|
|
||||||
pub expires_at_unix_secs: Option<u64>,
|
|
||||||
pub generated_by_agent_version: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
||||||
pub enum CommitmentValidationError {
|
|
||||||
MissingId,
|
|
||||||
MissingNextAction,
|
|
||||||
MissingSuccessCondition,
|
|
||||||
MissingTimebox,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl fmt::Display for CommitmentValidationError {
|
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
||||||
match self {
|
|
||||||
CommitmentValidationError::MissingId => write!(f, "commitment id is required"),
|
|
||||||
CommitmentValidationError::MissingNextAction => write!(f, "next action is required"),
|
|
||||||
CommitmentValidationError::MissingSuccessCondition => {
|
|
||||||
write!(f, "success condition is required")
|
|
||||||
}
|
|
||||||
CommitmentValidationError::MissingTimebox => write!(f, "timebox must be nonzero"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::error::Error for CommitmentValidationError {}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
||||||
pub enum PolicySnapshotValidationError {
|
|
||||||
WrongSchemaVersion,
|
|
||||||
MissingId,
|
|
||||||
MissingCommitmentId,
|
|
||||||
MissingGeneratedByAgentVersion,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl fmt::Display for PolicySnapshotValidationError {
|
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
||||||
match self {
|
|
||||||
PolicySnapshotValidationError::WrongSchemaVersion => {
|
|
||||||
write!(f, "policy schema version is unsupported")
|
|
||||||
}
|
|
||||||
PolicySnapshotValidationError::MissingId => write!(f, "policy id is required"),
|
|
||||||
PolicySnapshotValidationError::MissingCommitmentId => {
|
|
||||||
write!(f, "commitment id is required")
|
|
||||||
}
|
|
||||||
PolicySnapshotValidationError::MissingGeneratedByAgentVersion => {
|
|
||||||
write!(f, "generated agent version is required")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::error::Error for PolicySnapshotValidationError {}
|
|
||||||
|
|
||||||
impl Commitment {
|
|
||||||
pub fn new_manual(
|
|
||||||
next_action: impl Into<String>,
|
|
||||||
success_condition: impl Into<String>,
|
|
||||||
timebox: Duration,
|
|
||||||
) -> Result<Self, CommitmentValidationError> {
|
|
||||||
let next_action = next_action.into().trim().to_string();
|
|
||||||
let success_condition = success_condition.into().trim().to_string();
|
|
||||||
|
|
||||||
if timebox.as_secs() == 0 {
|
|
||||||
return Err(CommitmentValidationError::MissingTimebox);
|
|
||||||
}
|
|
||||||
|
|
||||||
let created_at_unix_secs = unix_secs_now();
|
|
||||||
let commitment = Self {
|
|
||||||
id: unique_id("commitment"),
|
|
||||||
created_at_unix_secs,
|
|
||||||
source: CommitmentSource::Manual,
|
|
||||||
project_id: None,
|
|
||||||
template_id: None,
|
|
||||||
next_action,
|
|
||||||
success_condition,
|
|
||||||
timebox_secs: timebox.as_secs(),
|
|
||||||
transition_policy_id: None,
|
|
||||||
state: CommitmentState::Draft,
|
|
||||||
};
|
|
||||||
commitment.validate()?;
|
|
||||||
Ok(commitment)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn validate(&self) -> Result<(), CommitmentValidationError> {
|
|
||||||
if self.id.trim().is_empty() {
|
|
||||||
return Err(CommitmentValidationError::MissingId);
|
|
||||||
}
|
|
||||||
if self.next_action.trim().is_empty() {
|
|
||||||
return Err(CommitmentValidationError::MissingNextAction);
|
|
||||||
}
|
|
||||||
if self.success_condition.trim().is_empty() {
|
|
||||||
return Err(CommitmentValidationError::MissingSuccessCondition);
|
|
||||||
}
|
|
||||||
if self.timebox_secs == 0 {
|
|
||||||
return Err(CommitmentValidationError::MissingTimebox);
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PolicySnapshot {
|
|
||||||
pub fn for_runtime(
|
|
||||||
commitment_id: String,
|
|
||||||
runtime_state: RuntimeState,
|
|
||||||
enforcement_level: EnforcementLevel,
|
|
||||||
allowed_context: AllowedContext,
|
|
||||||
) -> Self {
|
|
||||||
Self::try_for_runtime(
|
|
||||||
commitment_id,
|
|
||||||
runtime_state,
|
|
||||||
enforcement_level,
|
|
||||||
allowed_context,
|
|
||||||
)
|
|
||||||
.expect("runtime policy snapshot requires a non-empty commitment id")
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn try_for_runtime(
|
|
||||||
commitment_id: String,
|
|
||||||
runtime_state: RuntimeState,
|
|
||||||
enforcement_level: EnforcementLevel,
|
|
||||||
allowed_context: AllowedContext,
|
|
||||||
) -> Result<Self, PolicySnapshotValidationError> {
|
|
||||||
let commitment_id = commitment_id.trim().to_string();
|
|
||||||
if commitment_id.is_empty() {
|
|
||||||
return Err(PolicySnapshotValidationError::MissingCommitmentId);
|
|
||||||
}
|
|
||||||
|
|
||||||
let created_at_unix_secs = unix_secs_now();
|
|
||||||
let policy = Self {
|
|
||||||
id: unique_id("policy"),
|
|
||||||
commitment_id,
|
|
||||||
schema_version: POLICY_SCHEMA_VERSION,
|
|
||||||
created_at_unix_secs,
|
|
||||||
runtime_state,
|
|
||||||
enforcement_level,
|
|
||||||
allowed_context,
|
|
||||||
required_monitors: Vec::new(),
|
|
||||||
violation_actions: Vec::new(),
|
|
||||||
expires_at_unix_secs: None,
|
|
||||||
generated_by_agent_version: env!("CARGO_PKG_VERSION").to_string(),
|
|
||||||
};
|
|
||||||
policy.validate()?;
|
|
||||||
Ok(policy)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn validate(&self) -> Result<(), PolicySnapshotValidationError> {
|
|
||||||
if self.schema_version != POLICY_SCHEMA_VERSION {
|
|
||||||
return Err(PolicySnapshotValidationError::WrongSchemaVersion);
|
|
||||||
}
|
|
||||||
if self.id.trim().is_empty() {
|
|
||||||
return Err(PolicySnapshotValidationError::MissingId);
|
|
||||||
}
|
|
||||||
if self.commitment_id.trim().is_empty() {
|
|
||||||
return Err(PolicySnapshotValidationError::MissingCommitmentId);
|
|
||||||
}
|
|
||||||
if self.generated_by_agent_version.trim().is_empty() {
|
|
||||||
return Err(PolicySnapshotValidationError::MissingGeneratedByAgentVersion);
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn unix_secs_now() -> u64 {
|
|
||||||
SystemTime::now()
|
|
||||||
.duration_since(UNIX_EPOCH)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.as_secs()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn unique_id(prefix: &str) -> String {
|
|
||||||
format!("{prefix}-{}", Uuid::now_v7())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn commitment_requires_concrete_action_and_success_condition() {
|
|
||||||
let err = Commitment::new_manual("", "tests pass", Duration::from_secs(1500))
|
|
||||||
.expect_err("empty next action must be rejected");
|
|
||||||
assert_eq!(err, CommitmentValidationError::MissingNextAction);
|
|
||||||
|
|
||||||
let err = Commitment::new_manual("Refactor state", "", Duration::from_secs(1500))
|
|
||||||
.expect_err("empty success condition must be rejected");
|
|
||||||
assert_eq!(err, CommitmentValidationError::MissingSuccessCondition);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn commitment_requires_nonzero_timebox() {
|
|
||||||
let err = Commitment::new_manual("Refactor state", "tests pass", Duration::ZERO)
|
|
||||||
.expect_err("zero timebox must be rejected");
|
|
||||||
assert_eq!(err, CommitmentValidationError::MissingTimebox);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn policy_snapshot_carries_runtime_contract() {
|
|
||||||
let commitment = Commitment::new_manual(
|
|
||||||
"Implement domain types",
|
|
||||||
"domain tests pass",
|
|
||||||
Duration::from_secs(1500),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
let policy = PolicySnapshot::for_runtime(
|
|
||||||
commitment.id.clone(),
|
|
||||||
RuntimeState::Active,
|
|
||||||
EnforcementLevel::Warn,
|
|
||||||
AllowedContext::default(),
|
|
||||||
);
|
|
||||||
|
|
||||||
assert_eq!(policy.commitment_id, commitment.id);
|
|
||||||
assert_eq!(policy.runtime_state, RuntimeState::Active);
|
|
||||||
assert_eq!(policy.enforcement_level, EnforcementLevel::Warn);
|
|
||||||
assert_eq!(policy.schema_version, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn commitment_ids_are_unique_for_rapid_consecutive_construction() {
|
|
||||||
let first = Commitment::new_manual(
|
|
||||||
"Implement first action",
|
|
||||||
"first tests pass",
|
|
||||||
Duration::from_secs(1500),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
let second = Commitment::new_manual(
|
|
||||||
"Implement second action",
|
|
||||||
"second tests pass",
|
|
||||||
Duration::from_secs(1500),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert_ne!(first.id, second.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn policy_snapshot_ids_are_unique_for_rapid_consecutive_construction() {
|
|
||||||
let first = PolicySnapshot::for_runtime(
|
|
||||||
"commitment-1".to_string(),
|
|
||||||
RuntimeState::Active,
|
|
||||||
EnforcementLevel::Warn,
|
|
||||||
AllowedContext::default(),
|
|
||||||
);
|
|
||||||
let second = PolicySnapshot::for_runtime(
|
|
||||||
"commitment-1".to_string(),
|
|
||||||
RuntimeState::Active,
|
|
||||||
EnforcementLevel::Warn,
|
|
||||||
AllowedContext::default(),
|
|
||||||
);
|
|
||||||
|
|
||||||
assert_ne!(first.id, second.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn commitment_serializes_enum_contract_as_snake_case() {
|
|
||||||
let commitment = Commitment::new_manual(
|
|
||||||
"Implement domain contracts",
|
|
||||||
"domain tests pass",
|
|
||||||
Duration::from_secs(1500),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let json = serde_json::to_string(&commitment).unwrap();
|
|
||||||
|
|
||||||
assert!(json.contains(r#""source":"manual""#));
|
|
||||||
assert!(json.contains(r#""state":"draft""#));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn policy_snapshot_serializes_enum_contract_as_snake_case() {
|
|
||||||
let policy = PolicySnapshot::for_runtime(
|
|
||||||
"commitment-1".to_string(),
|
|
||||||
RuntimeState::Active,
|
|
||||||
EnforcementLevel::Warn,
|
|
||||||
AllowedContext::default(),
|
|
||||||
);
|
|
||||||
|
|
||||||
let json = serde_json::to_string(&policy).unwrap();
|
|
||||||
|
|
||||||
assert!(json.contains(r#""runtime_state":"active""#));
|
|
||||||
assert!(json.contains(r#""enforcement_level":"warn""#));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn commitment_validate_rejects_invalid_public_field_values() {
|
|
||||||
let mut commitment =
|
|
||||||
Commitment::new_manual("Refactor state", "tests pass", Duration::from_secs(1500))
|
|
||||||
.unwrap();
|
|
||||||
commitment.id = " ".to_string();
|
|
||||||
assert_eq!(
|
|
||||||
commitment.validate(),
|
|
||||||
Err(CommitmentValidationError::MissingId)
|
|
||||||
);
|
|
||||||
|
|
||||||
commitment.id = "commitment-1".to_string();
|
|
||||||
commitment.next_action = " ".to_string();
|
|
||||||
assert_eq!(
|
|
||||||
commitment.validate(),
|
|
||||||
Err(CommitmentValidationError::MissingNextAction)
|
|
||||||
);
|
|
||||||
|
|
||||||
commitment.next_action = "Refactor state".to_string();
|
|
||||||
commitment.success_condition = " ".to_string();
|
|
||||||
assert_eq!(
|
|
||||||
commitment.validate(),
|
|
||||||
Err(CommitmentValidationError::MissingSuccessCondition)
|
|
||||||
);
|
|
||||||
|
|
||||||
commitment.success_condition = "tests pass".to_string();
|
|
||||||
commitment.timebox_secs = 0;
|
|
||||||
assert_eq!(
|
|
||||||
commitment.validate(),
|
|
||||||
Err(CommitmentValidationError::MissingTimebox)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn commitment_validate_rejects_invalid_deserialized_values() {
|
|
||||||
let json = r#"{
|
|
||||||
"id": "",
|
|
||||||
"created_at_unix_secs": 1,
|
|
||||||
"source": "manual",
|
|
||||||
"project_id": null,
|
|
||||||
"template_id": null,
|
|
||||||
"next_action": "Refactor state",
|
|
||||||
"success_condition": "tests pass",
|
|
||||||
"timebox_secs": 1500,
|
|
||||||
"transition_policy_id": null,
|
|
||||||
"state": "draft"
|
|
||||||
}"#;
|
|
||||||
let commitment: Commitment = serde_json::from_str(json).unwrap();
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
commitment.validate(),
|
|
||||||
Err(CommitmentValidationError::MissingId)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn validation_errors_implement_std_error() {
|
|
||||||
fn assert_std_error<E: std::error::Error>() {}
|
|
||||||
|
|
||||||
assert_std_error::<CommitmentValidationError>();
|
|
||||||
assert_std_error::<PolicySnapshotValidationError>();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn policy_snapshot_try_for_runtime_rejects_empty_commitment_id() {
|
|
||||||
let err = PolicySnapshot::try_for_runtime(
|
|
||||||
" ".to_string(),
|
|
||||||
RuntimeState::Active,
|
|
||||||
EnforcementLevel::Warn,
|
|
||||||
AllowedContext::default(),
|
|
||||||
)
|
|
||||||
.expect_err("empty commitment id must be rejected");
|
|
||||||
|
|
||||||
assert_eq!(err, PolicySnapshotValidationError::MissingCommitmentId);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn policy_snapshot_validate_rejects_invalid_public_field_values() {
|
|
||||||
let mut policy = PolicySnapshot::for_runtime(
|
|
||||||
"commitment-1".to_string(),
|
|
||||||
RuntimeState::Active,
|
|
||||||
EnforcementLevel::Warn,
|
|
||||||
AllowedContext::default(),
|
|
||||||
);
|
|
||||||
policy.schema_version = POLICY_SCHEMA_VERSION + 1;
|
|
||||||
assert_eq!(
|
|
||||||
policy.validate(),
|
|
||||||
Err(PolicySnapshotValidationError::WrongSchemaVersion)
|
|
||||||
);
|
|
||||||
|
|
||||||
policy.schema_version = POLICY_SCHEMA_VERSION;
|
|
||||||
policy.id = " ".to_string();
|
|
||||||
assert_eq!(
|
|
||||||
policy.validate(),
|
|
||||||
Err(PolicySnapshotValidationError::MissingId)
|
|
||||||
);
|
|
||||||
|
|
||||||
policy.id = "policy-1".to_string();
|
|
||||||
policy.commitment_id = " ".to_string();
|
|
||||||
assert_eq!(
|
|
||||||
policy.validate(),
|
|
||||||
Err(PolicySnapshotValidationError::MissingCommitmentId)
|
|
||||||
);
|
|
||||||
|
|
||||||
policy.commitment_id = "commitment-1".to_string();
|
|
||||||
policy.generated_by_agent_version = " ".to_string();
|
|
||||||
assert_eq!(
|
|
||||||
policy.validate(),
|
|
||||||
Err(PolicySnapshotValidationError::MissingGeneratedByAgentVersion)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn policy_snapshot_validate_rejects_invalid_deserialized_values() {
|
|
||||||
let json = r#"{
|
|
||||||
"id": "",
|
|
||||||
"commitment_id": "commitment-1",
|
|
||||||
"schema_version": 1,
|
|
||||||
"created_at_unix_secs": 1,
|
|
||||||
"runtime_state": "active",
|
|
||||||
"enforcement_level": "warn",
|
|
||||||
"allowed_context": {
|
|
||||||
"window_classes": [],
|
|
||||||
"window_title_substrings": [],
|
|
||||||
"domains": [],
|
|
||||||
"repos": [],
|
|
||||||
"commands": []
|
|
||||||
},
|
|
||||||
"required_monitors": [],
|
|
||||||
"violation_actions": [],
|
|
||||||
"expires_at_unix_secs": null,
|
|
||||||
"generated_by_agent_version": "0.1.0"
|
|
||||||
}"#;
|
|
||||||
let policy: PolicySnapshot = serde_json::from_str(json).unwrap();
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
policy.validate(),
|
|
||||||
Err(PolicySnapshotValidationError::MissingId)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,583 +0,0 @@
|
|||||||
use crate::domain::{unix_secs_now, RuntimeState, POLICY_SCHEMA_VERSION};
|
|
||||||
use anyhow::{anyhow, Context, Result};
|
|
||||||
use fs2::FileExt;
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use serde_json::Value;
|
|
||||||
use sha2::{Digest, Sha256};
|
|
||||||
use std::fs::{self, File, OpenOptions};
|
|
||||||
use std::io::{BufRead, BufReader, Seek, SeekFrom, Write};
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum EventType {
|
|
||||||
CommitmentCreated,
|
|
||||||
RuntimeTransition,
|
|
||||||
CommitmentTransition,
|
|
||||||
PolicyApplied,
|
|
||||||
EvidenceObserved,
|
|
||||||
ViolationObserved,
|
|
||||||
TransitionStarted,
|
|
||||||
ReviewCompleted,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
|
||||||
#[serde(deny_unknown_fields)]
|
|
||||||
pub struct EventRecord {
|
|
||||||
pub schema_version: u16,
|
|
||||||
pub sequence: u64,
|
|
||||||
pub timestamp_unix_secs: u64,
|
|
||||||
pub event_type: EventType,
|
|
||||||
pub commitment_id: Option<String>,
|
|
||||||
pub runtime_state: RuntimeState,
|
|
||||||
pub payload_json: Value,
|
|
||||||
pub previous_hash: Option<String>,
|
|
||||||
pub hash: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq)]
|
|
||||||
pub struct PendingEventRecord {
|
|
||||||
pub event_type: EventType,
|
|
||||||
pub runtime_state: RuntimeState,
|
|
||||||
pub commitment_id: Option<String>,
|
|
||||||
pub payload_json: Value,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct EventLog {
|
|
||||||
path: PathBuf,
|
|
||||||
next_sequence: u64,
|
|
||||||
previous_hash: Option<String>,
|
|
||||||
needs_parent_sync_after_append: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl EventLog {
|
|
||||||
pub fn open(path: impl AsRef<Path>) -> Result<Self> {
|
|
||||||
let path = path.as_ref().to_path_buf();
|
|
||||||
create_parent_dirs(&path)?;
|
|
||||||
|
|
||||||
let (mut file, created) = open_log_file(&path)?;
|
|
||||||
file.lock_shared()
|
|
||||||
.with_context(|| format!("lock event log for read {}", path.display()))?;
|
|
||||||
let (next_sequence, previous_hash) = load_tail_locked(&mut file, &path)?;
|
|
||||||
|
|
||||||
Ok(Self {
|
|
||||||
path,
|
|
||||||
next_sequence,
|
|
||||||
previous_hash,
|
|
||||||
needs_parent_sync_after_append: created,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn append(
|
|
||||||
&mut self,
|
|
||||||
event_type: EventType,
|
|
||||||
runtime_state: RuntimeState,
|
|
||||||
commitment_id: Option<String>,
|
|
||||||
payload_json: Value,
|
|
||||||
) -> Result<EventRecord> {
|
|
||||||
let mut records = self.append_batch([PendingEventRecord {
|
|
||||||
event_type,
|
|
||||||
runtime_state,
|
|
||||||
commitment_id,
|
|
||||||
payload_json,
|
|
||||||
}])?;
|
|
||||||
records
|
|
||||||
.pop()
|
|
||||||
.ok_or_else(|| anyhow!("single event append produced no record"))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn append_batch(
|
|
||||||
&mut self,
|
|
||||||
events: impl IntoIterator<Item = PendingEventRecord>,
|
|
||||||
) -> Result<Vec<EventRecord>> {
|
|
||||||
let events: Vec<PendingEventRecord> = events.into_iter().collect();
|
|
||||||
if events.is_empty() {
|
|
||||||
return Ok(Vec::new());
|
|
||||||
}
|
|
||||||
|
|
||||||
create_parent_dirs(&self.path)?;
|
|
||||||
|
|
||||||
let existed = self
|
|
||||||
.path
|
|
||||||
.try_exists()
|
|
||||||
.with_context(|| format!("check whether event log exists {}", self.path.display()))?;
|
|
||||||
let mut file = OpenOptions::new()
|
|
||||||
.create(true)
|
|
||||||
.read(true)
|
|
||||||
.append(true)
|
|
||||||
.open(&self.path)
|
|
||||||
.with_context(|| format!("open event log for append {}", self.path.display()))?;
|
|
||||||
file.lock_exclusive()
|
|
||||||
.with_context(|| format!("lock event log {}", self.path.display()))?;
|
|
||||||
|
|
||||||
let (next_sequence, previous_hash) = load_tail_locked(&mut file, &self.path)?;
|
|
||||||
|
|
||||||
let mut records = Vec::with_capacity(events.len());
|
|
||||||
let mut sequence = next_sequence;
|
|
||||||
let mut record_previous_hash = previous_hash;
|
|
||||||
for event in events {
|
|
||||||
let mut record = EventRecord {
|
|
||||||
schema_version: POLICY_SCHEMA_VERSION,
|
|
||||||
sequence,
|
|
||||||
timestamp_unix_secs: unix_secs_now(),
|
|
||||||
event_type: event.event_type,
|
|
||||||
commitment_id: event.commitment_id,
|
|
||||||
runtime_state: event.runtime_state,
|
|
||||||
payload_json: event.payload_json,
|
|
||||||
previous_hash: record_previous_hash,
|
|
||||||
hash: String::new(),
|
|
||||||
};
|
|
||||||
record.hash = record_hash(&record)?;
|
|
||||||
record_previous_hash = Some(record.hash.clone());
|
|
||||||
sequence += 1;
|
|
||||||
records.push(record);
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut bytes = Vec::new();
|
|
||||||
for record in &records {
|
|
||||||
serde_json::to_writer(&mut bytes, record).with_context(|| {
|
|
||||||
format!("serialize event log record to {}", self.path.display())
|
|
||||||
})?;
|
|
||||||
bytes.push(b'\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
file.write_all(&bytes).with_context(|| {
|
|
||||||
format!(
|
|
||||||
"write {} event log records to {}",
|
|
||||||
records.len(),
|
|
||||||
self.path.display()
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
file.flush()
|
|
||||||
.with_context(|| format!("flush event log {}", self.path.display()))?;
|
|
||||||
file.sync_data()
|
|
||||||
.with_context(|| format!("sync event log {}", self.path.display()))?;
|
|
||||||
if !existed || self.needs_parent_sync_after_append {
|
|
||||||
sync_parent_dir(&self.path)?;
|
|
||||||
self.needs_parent_sync_after_append = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
self.next_sequence = sequence;
|
|
||||||
self.previous_hash = record_previous_hash;
|
|
||||||
|
|
||||||
Ok(records)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn records(&self) -> Result<Vec<EventRecord>> {
|
|
||||||
let mut file = OpenOptions::new()
|
|
||||||
.read(true)
|
|
||||||
.open(&self.path)
|
|
||||||
.with_context(|| format!("open event log for read {}", self.path.display()))?;
|
|
||||||
file.lock_shared()
|
|
||||||
.with_context(|| format!("lock event log for read {}", self.path.display()))?;
|
|
||||||
load_records_locked(&mut file, &self.path)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn create_parent_dirs(path: &Path) -> Result<()> {
|
|
||||||
if let Some(parent) = path.parent() {
|
|
||||||
fs::create_dir_all(parent).with_context(|| {
|
|
||||||
format!("create parent directories for event log {}", path.display())
|
|
||||||
})?;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn open_log_file(path: &Path) -> Result<(File, bool)> {
|
|
||||||
let existed = path
|
|
||||||
.try_exists()
|
|
||||||
.with_context(|| format!("check whether event log exists {}", path.display()))?;
|
|
||||||
let file = OpenOptions::new()
|
|
||||||
.create(true)
|
|
||||||
.read(true)
|
|
||||||
.append(true)
|
|
||||||
.open(path)
|
|
||||||
.with_context(|| format!("open event log {}", path.display()))?;
|
|
||||||
Ok((file, !existed))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn load_tail_locked(file: &mut File, path: &Path) -> Result<(u64, Option<String>)> {
|
|
||||||
let records = load_records_locked(file, path)?;
|
|
||||||
let next_sequence = records.last().map_or(1, |record| record.sequence + 1);
|
|
||||||
let previous_hash = records.last().map(|record| record.hash.clone());
|
|
||||||
Ok((next_sequence, previous_hash))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn load_records_locked(file: &mut File, path: &Path) -> Result<Vec<EventRecord>> {
|
|
||||||
let mut next_sequence = 1;
|
|
||||||
let mut previous_hash = None;
|
|
||||||
let mut records = Vec::new();
|
|
||||||
|
|
||||||
file.seek(SeekFrom::Start(0))
|
|
||||||
.with_context(|| format!("seek event log {}", path.display()))?;
|
|
||||||
for (line_index, line) in BufReader::new(file).lines().enumerate() {
|
|
||||||
let line_number = line_index + 1;
|
|
||||||
let line = line.with_context(|| {
|
|
||||||
format!("read event log line {line_number} from {}", path.display())
|
|
||||||
})?;
|
|
||||||
if line.trim().is_empty() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let record: EventRecord = serde_json::from_str(&line).with_context(|| {
|
|
||||||
format!("parse event log line {line_number} from {}", path.display())
|
|
||||||
})?;
|
|
||||||
validate_record(&record, next_sequence, previous_hash.as_deref())
|
|
||||||
.map_err(|err| anyhow!("validate event log line {line_number}: {err}"))?;
|
|
||||||
|
|
||||||
next_sequence += 1;
|
|
||||||
previous_hash = Some(record.hash.clone());
|
|
||||||
records.push(record);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(records)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(unix)]
|
|
||||||
fn sync_parent_dir(path: &Path) -> Result<()> {
|
|
||||||
if let Some(parent) = path.parent() {
|
|
||||||
let dir = File::open(parent)
|
|
||||||
.with_context(|| format!("open parent directory for event log {}", path.display()))?;
|
|
||||||
dir.sync_all().with_context(|| {
|
|
||||||
format!(
|
|
||||||
"sync parent directory {} for event log {}",
|
|
||||||
parent.display(),
|
|
||||||
path.display()
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(not(unix))]
|
|
||||||
fn sync_parent_dir(_path: &Path) -> Result<()> {
|
|
||||||
// Directory fsync is not exposed portably by std on all supported platforms.
|
|
||||||
// Linux and other Unix targets take the durable path above.
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn validate_record(
|
|
||||||
record: &EventRecord,
|
|
||||||
expected_sequence: u64,
|
|
||||||
expected_previous_hash: Option<&str>,
|
|
||||||
) -> Result<()> {
|
|
||||||
if record.schema_version != POLICY_SCHEMA_VERSION {
|
|
||||||
return Err(anyhow!(
|
|
||||||
"unsupported schema version at sequence {}: expected {}, found {}",
|
|
||||||
record.sequence,
|
|
||||||
POLICY_SCHEMA_VERSION,
|
|
||||||
record.schema_version
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if record.sequence != expected_sequence {
|
|
||||||
return Err(anyhow!(
|
|
||||||
"sequence mismatch: expected {}, found {}",
|
|
||||||
expected_sequence,
|
|
||||||
record.sequence
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if record.previous_hash.as_deref() != expected_previous_hash {
|
|
||||||
return Err(anyhow!(
|
|
||||||
"previous hash mismatch at sequence {}",
|
|
||||||
record.sequence
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let expected_hash = record_hash(record)?;
|
|
||||||
if record.hash != expected_hash {
|
|
||||||
return Err(anyhow!("hash mismatch at sequence {}", record.sequence));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn record_hash(record: &EventRecord) -> Result<String> {
|
|
||||||
let mut hashable = record.clone();
|
|
||||||
hashable.hash.clear();
|
|
||||||
let bytes = serde_json::to_vec(&hashable).context("serialize event log record for hash")?;
|
|
||||||
Ok(hex::encode(Sha256::digest(bytes)))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use crate::domain::RuntimeState;
|
|
||||||
use serde_json::json;
|
|
||||||
use std::fs;
|
|
||||||
use std::path::PathBuf;
|
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
|
||||||
|
|
||||||
fn temp_log_path(name: &str) -> PathBuf {
|
|
||||||
let nonce = SystemTime::now()
|
|
||||||
.duration_since(UNIX_EPOCH)
|
|
||||||
.unwrap()
|
|
||||||
.as_nanos();
|
|
||||||
std::env::temp_dir().join(format!(
|
|
||||||
"antidrift-event-log-{name}-{}-{nonce}.jsonl",
|
|
||||||
std::process::id()
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn appends_hash_chained_jsonl_records() {
|
|
||||||
let path = temp_log_path("append");
|
|
||||||
let mut log = EventLog::open(&path).unwrap();
|
|
||||||
|
|
||||||
let first = log
|
|
||||||
.append(
|
|
||||||
EventType::RuntimeTransition,
|
|
||||||
RuntimeState::Transition,
|
|
||||||
Some("commitment-1".to_string()),
|
|
||||||
json!({"from": "active", "to": "transition"}),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
let second = log
|
|
||||||
.append(
|
|
||||||
EventType::PolicyApplied,
|
|
||||||
RuntimeState::Active,
|
|
||||||
Some("commitment-1".to_string()),
|
|
||||||
json!({"policy_id": "policy-1"}),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert_eq!(first.sequence, 1);
|
|
||||||
assert_eq!(second.sequence, 2);
|
|
||||||
assert_eq!(second.previous_hash.as_deref(), Some(first.hash.as_str()));
|
|
||||||
assert_ne!(first.hash, second.hash);
|
|
||||||
|
|
||||||
let contents = fs::read_to_string(&path).unwrap();
|
|
||||||
assert_eq!(contents.lines().count(), 2);
|
|
||||||
|
|
||||||
fs::remove_file(path).unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn batch_appends_hash_chained_adjacent_records() {
|
|
||||||
let path = temp_log_path("batch");
|
|
||||||
let mut log = EventLog::open(&path).unwrap();
|
|
||||||
|
|
||||||
let records = log
|
|
||||||
.append_batch([
|
|
||||||
PendingEventRecord {
|
|
||||||
event_type: EventType::CommitmentCreated,
|
|
||||||
runtime_state: RuntimeState::Active,
|
|
||||||
commitment_id: Some("commitment-1".to_string()),
|
|
||||||
payload_json: json!({"commitment_id": "commitment-1"}),
|
|
||||||
},
|
|
||||||
PendingEventRecord {
|
|
||||||
event_type: EventType::PolicyApplied,
|
|
||||||
runtime_state: RuntimeState::Active,
|
|
||||||
commitment_id: Some("commitment-1".to_string()),
|
|
||||||
payload_json: json!({"policy_id": "policy-1"}),
|
|
||||||
},
|
|
||||||
])
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert_eq!(records.len(), 2);
|
|
||||||
assert_eq!(records[0].sequence, 1);
|
|
||||||
assert_eq!(records[1].sequence, 2);
|
|
||||||
assert_eq!(
|
|
||||||
records[1].previous_hash.as_deref(),
|
|
||||||
Some(records[0].hash.as_str())
|
|
||||||
);
|
|
||||||
|
|
||||||
let contents = fs::read_to_string(&path).unwrap();
|
|
||||||
assert_eq!(contents.lines().count(), 2);
|
|
||||||
EventLog::open(&path).unwrap();
|
|
||||||
|
|
||||||
fs::remove_file(path).unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn opening_existing_log_validates_chain_and_continues_sequence() {
|
|
||||||
let path = temp_log_path("reopen");
|
|
||||||
{
|
|
||||||
let mut log = EventLog::open(&path).unwrap();
|
|
||||||
log.append(
|
|
||||||
EventType::RuntimeTransition,
|
|
||||||
RuntimeState::Transition,
|
|
||||||
None,
|
|
||||||
json!({"from": "active"}),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut reopened = EventLog::open(&path).unwrap();
|
|
||||||
let next = reopened
|
|
||||||
.append(
|
|
||||||
EventType::PolicyApplied,
|
|
||||||
RuntimeState::Active,
|
|
||||||
None,
|
|
||||||
json!({"policy_id": "policy-1"}),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert_eq!(next.sequence, 2);
|
|
||||||
|
|
||||||
fs::remove_file(path).unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn opening_missing_log_creates_reopenable_file() {
|
|
||||||
let path = temp_log_path("open-missing");
|
|
||||||
|
|
||||||
EventLog::open(&path).unwrap();
|
|
||||||
|
|
||||||
assert!(path.exists());
|
|
||||||
EventLog::open(&path).unwrap();
|
|
||||||
fs::remove_file(path).unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn opening_malformed_json_log_fails() {
|
|
||||||
let path = temp_log_path("malformed");
|
|
||||||
fs::write(&path, "{not json}\n").unwrap();
|
|
||||||
|
|
||||||
let err = EventLog::open(&path).expect_err("malformed JSONL must fail");
|
|
||||||
|
|
||||||
assert!(err.to_string().contains("parse event log"));
|
|
||||||
fs::remove_file(path).unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn opening_tampered_log_chain_fails() {
|
|
||||||
let path = temp_log_path("tampered");
|
|
||||||
{
|
|
||||||
let mut log = EventLog::open(&path).unwrap();
|
|
||||||
log.append(
|
|
||||||
EventType::RuntimeTransition,
|
|
||||||
RuntimeState::Transition,
|
|
||||||
None,
|
|
||||||
json!({"from": "active"}),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
log.append(
|
|
||||||
EventType::PolicyApplied,
|
|
||||||
RuntimeState::Active,
|
|
||||||
None,
|
|
||||||
json!({"policy_id": "policy-1"}),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
}
|
|
||||||
let contents = fs::read_to_string(&path).unwrap();
|
|
||||||
fs::write(&path, contents.replace("\"sequence\":2", "\"sequence\":3")).unwrap();
|
|
||||||
|
|
||||||
let err = EventLog::open(&path).expect_err("tampered chain must fail");
|
|
||||||
|
|
||||||
assert!(err.to_string().contains("sequence mismatch"));
|
|
||||||
fs::remove_file(path).unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn opening_log_with_unknown_record_field_fails() {
|
|
||||||
let path = temp_log_path("unknown-field");
|
|
||||||
{
|
|
||||||
let mut log = EventLog::open(&path).unwrap();
|
|
||||||
log.append(
|
|
||||||
EventType::RuntimeTransition,
|
|
||||||
RuntimeState::Transition,
|
|
||||||
None,
|
|
||||||
json!({"from": "active"}),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
}
|
|
||||||
let contents = fs::read_to_string(&path).unwrap();
|
|
||||||
let mut record_json: Value =
|
|
||||||
serde_json::from_str(contents.lines().next().unwrap()).unwrap();
|
|
||||||
record_json["unexpected_field"] = json!("ignored-before-hashing");
|
|
||||||
fs::write(
|
|
||||||
&path,
|
|
||||||
format!("{}\n", serde_json::to_string(&record_json).unwrap()),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let err = EventLog::open(&path).expect_err("unknown record fields must fail");
|
|
||||||
|
|
||||||
assert!(err.to_string().contains("parse event log"));
|
|
||||||
fs::remove_file(path).unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn opening_log_with_unsupported_schema_version_fails() {
|
|
||||||
let path = temp_log_path("unsupported-schema");
|
|
||||||
let mut record = EventRecord {
|
|
||||||
schema_version: 999,
|
|
||||||
sequence: 1,
|
|
||||||
timestamp_unix_secs: unix_secs_now(),
|
|
||||||
event_type: EventType::RuntimeTransition,
|
|
||||||
commitment_id: None,
|
|
||||||
runtime_state: RuntimeState::Transition,
|
|
||||||
payload_json: json!({"from": "active"}),
|
|
||||||
previous_hash: None,
|
|
||||||
hash: String::new(),
|
|
||||||
};
|
|
||||||
record.hash = record_hash(&record).unwrap();
|
|
||||||
fs::write(
|
|
||||||
&path,
|
|
||||||
format!("{}\n", serde_json::to_string(&record).unwrap()),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let err = EventLog::open(&path).expect_err("unsupported schema version must fail");
|
|
||||||
|
|
||||||
assert!(err.to_string().contains("unsupported schema version"));
|
|
||||||
fs::remove_file(path).unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn stale_handle_reloads_tail_before_append() {
|
|
||||||
let path = temp_log_path("stale-handle");
|
|
||||||
let mut log1 = EventLog::open(&path).unwrap();
|
|
||||||
let mut log2 = EventLog::open(&path).unwrap();
|
|
||||||
|
|
||||||
let first = log1
|
|
||||||
.append(
|
|
||||||
EventType::RuntimeTransition,
|
|
||||||
RuntimeState::Transition,
|
|
||||||
None,
|
|
||||||
json!({"from": "active"}),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
let second = log2
|
|
||||||
.append(
|
|
||||||
EventType::PolicyApplied,
|
|
||||||
RuntimeState::Active,
|
|
||||||
None,
|
|
||||||
json!({"policy_id": "policy-1"}),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert_eq!(second.sequence, 2);
|
|
||||||
assert_eq!(second.previous_hash.as_deref(), Some(first.hash.as_str()));
|
|
||||||
EventLog::open(&path).unwrap();
|
|
||||||
fs::remove_file(path).unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn append_creates_parent_dirs_and_writes_reopenable_file() {
|
|
||||||
let path = temp_log_path("append-parents")
|
|
||||||
.with_file_name("nested")
|
|
||||||
.join("events.jsonl");
|
|
||||||
let mut log = EventLog {
|
|
||||||
path: path.clone(),
|
|
||||||
next_sequence: 1,
|
|
||||||
previous_hash: None,
|
|
||||||
needs_parent_sync_after_append: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
let record = log
|
|
||||||
.append(
|
|
||||||
EventType::RuntimeTransition,
|
|
||||||
RuntimeState::Transition,
|
|
||||||
None,
|
|
||||||
json!({"from": "active"}),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert_eq!(record.sequence, 1);
|
|
||||||
EventLog::open(&path).unwrap();
|
|
||||||
fs::remove_file(&path).unwrap();
|
|
||||||
fs::remove_dir(path.parent().unwrap()).unwrap();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
pub mod context;
|
|
||||||
pub mod domain;
|
|
||||||
pub mod event_log;
|
|
||||||
pub mod session;
|
|
||||||
pub mod state_machine;
|
|
||||||
pub mod window;
|
|
||||||
-2121
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,327 +0,0 @@
|
|||||||
use crate::domain::{CommitmentState, RuntimeState};
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
||||||
pub enum RuntimeAction {
|
|
||||||
EnterPlanning,
|
|
||||||
CancelOrTimeout,
|
|
||||||
Activate { policy_accepted: bool },
|
|
||||||
StartTransition,
|
|
||||||
CompleteForReview,
|
|
||||||
SevereViolation,
|
|
||||||
ReturnToActive,
|
|
||||||
SwitchTask,
|
|
||||||
EndTransitionForReview,
|
|
||||||
ContinuePlanning,
|
|
||||||
EndWorkPeriod,
|
|
||||||
EnterAdminOverride,
|
|
||||||
ExitAdminOverride(RuntimeState),
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
||||||
pub enum CommitmentAction {
|
|
||||||
Activate,
|
|
||||||
PauseForTransition,
|
|
||||||
ReturnFromPause,
|
|
||||||
Complete,
|
|
||||||
Abandon,
|
|
||||||
Violate,
|
|
||||||
RecoverExplicitly,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
||||||
pub enum TransitionError {
|
|
||||||
PolicyRejected,
|
|
||||||
IllegalRuntimeTransition {
|
|
||||||
current: RuntimeState,
|
|
||||||
action: RuntimeAction,
|
|
||||||
},
|
|
||||||
IllegalCommitmentTransition {
|
|
||||||
current: CommitmentState,
|
|
||||||
action: CommitmentAction,
|
|
||||||
},
|
|
||||||
InvalidAdminOverrideExitTarget {
|
|
||||||
target: RuntimeState,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::fmt::Display for TransitionError {
|
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
||||||
match self {
|
|
||||||
TransitionError::PolicyRejected => write!(f, "policy was rejected"),
|
|
||||||
TransitionError::IllegalRuntimeTransition { current, action } => {
|
|
||||||
write!(
|
|
||||||
f,
|
|
||||||
"illegal runtime transition from {:?} via {:?}",
|
|
||||||
current, action
|
|
||||||
)
|
|
||||||
}
|
|
||||||
TransitionError::IllegalCommitmentTransition { current, action } => {
|
|
||||||
write!(
|
|
||||||
f,
|
|
||||||
"illegal commitment transition from {:?} via {:?}",
|
|
||||||
current, action
|
|
||||||
)
|
|
||||||
}
|
|
||||||
TransitionError::InvalidAdminOverrideExitTarget { target } => {
|
|
||||||
write!(f, "invalid admin override exit target {:?}", target)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::error::Error for TransitionError {}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
||||||
pub struct CompositeTransition {
|
|
||||||
pub runtime_state: RuntimeState,
|
|
||||||
pub commitment_state: CommitmentState,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn transition_runtime(
|
|
||||||
current: RuntimeState,
|
|
||||||
action: RuntimeAction,
|
|
||||||
) -> Result<RuntimeState, TransitionError> {
|
|
||||||
use RuntimeAction::*;
|
|
||||||
use RuntimeState::*;
|
|
||||||
|
|
||||||
match (current, action) {
|
|
||||||
(Locked, EnterPlanning) => Ok(Planning),
|
|
||||||
(
|
|
||||||
Planning,
|
|
||||||
Activate {
|
|
||||||
policy_accepted: true,
|
|
||||||
},
|
|
||||||
) => Ok(Active),
|
|
||||||
(
|
|
||||||
Planning,
|
|
||||||
Activate {
|
|
||||||
policy_accepted: false,
|
|
||||||
},
|
|
||||||
) => Err(TransitionError::PolicyRejected),
|
|
||||||
(Planning, CancelOrTimeout) => Ok(Locked),
|
|
||||||
(Active, StartTransition) => Ok(Transition),
|
|
||||||
(Active, CompleteForReview) => Ok(Review),
|
|
||||||
(Active, SevereViolation) => Ok(Locked),
|
|
||||||
(Transition, ReturnToActive) => Ok(Active),
|
|
||||||
(Transition, SwitchTask) => Ok(Planning),
|
|
||||||
(Transition, EndTransitionForReview) => Ok(Review),
|
|
||||||
(Transition, CancelOrTimeout) => Ok(Locked),
|
|
||||||
(Review, ContinuePlanning) => Ok(Planning),
|
|
||||||
(Review, EndWorkPeriod) => Ok(Locked),
|
|
||||||
(_, EnterAdminOverride) => Ok(AdminOverride),
|
|
||||||
(AdminOverride, ExitAdminOverride(Locked)) => Ok(Locked),
|
|
||||||
(AdminOverride, ExitAdminOverride(Planning)) => Ok(Planning),
|
|
||||||
(AdminOverride, ExitAdminOverride(target)) => {
|
|
||||||
Err(TransitionError::InvalidAdminOverrideExitTarget { target })
|
|
||||||
}
|
|
||||||
_ => Err(TransitionError::IllegalRuntimeTransition { current, action }),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn transition_commitment(
|
|
||||||
current: CommitmentState,
|
|
||||||
action: CommitmentAction,
|
|
||||||
) -> Result<CommitmentState, TransitionError> {
|
|
||||||
use CommitmentAction::*;
|
|
||||||
use CommitmentState::*;
|
|
||||||
|
|
||||||
match (current.clone(), action) {
|
|
||||||
(Draft, Activate) => Ok(Active),
|
|
||||||
(Active, PauseForTransition) => Ok(Paused),
|
|
||||||
(Paused, ReturnFromPause) => Ok(Active),
|
|
||||||
(Active, Complete) => Ok(Completed),
|
|
||||||
(Active, Abandon) => Ok(Abandoned),
|
|
||||||
(Active, Violate) => Ok(Violated),
|
|
||||||
(Paused, Abandon) => Ok(Abandoned),
|
|
||||||
(Violated, RecoverExplicitly) => Ok(Active),
|
|
||||||
(Violated, Abandon) => Ok(Abandoned),
|
|
||||||
_ => Err(TransitionError::IllegalCommitmentTransition { current, action }),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn start_transition(
|
|
||||||
runtime_state: RuntimeState,
|
|
||||||
commitment_state: CommitmentState,
|
|
||||||
) -> Result<CompositeTransition, TransitionError> {
|
|
||||||
let next_runtime = transition_runtime(runtime_state, RuntimeAction::StartTransition)?;
|
|
||||||
let next_commitment =
|
|
||||||
transition_commitment(commitment_state, CommitmentAction::PauseForTransition)?;
|
|
||||||
|
|
||||||
Ok(CompositeTransition {
|
|
||||||
runtime_state: next_runtime,
|
|
||||||
commitment_state: next_commitment,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn planning_can_only_activate_with_valid_policy() {
|
|
||||||
assert_eq!(
|
|
||||||
transition_runtime(
|
|
||||||
RuntimeState::Planning,
|
|
||||||
RuntimeAction::Activate {
|
|
||||||
policy_accepted: true,
|
|
||||||
}
|
|
||||||
),
|
|
||||||
Ok(RuntimeState::Active)
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
transition_runtime(
|
|
||||||
RuntimeState::Planning,
|
|
||||||
RuntimeAction::Activate {
|
|
||||||
policy_accepted: false,
|
|
||||||
}
|
|
||||||
),
|
|
||||||
Err(TransitionError::PolicyRejected)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn active_can_enter_transition_or_review_or_locked() {
|
|
||||||
assert_eq!(
|
|
||||||
transition_runtime(RuntimeState::Active, RuntimeAction::StartTransition),
|
|
||||||
Ok(RuntimeState::Transition)
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
transition_runtime(RuntimeState::Active, RuntimeAction::CompleteForReview),
|
|
||||||
Ok(RuntimeState::Review)
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
transition_runtime(RuntimeState::Active, RuntimeAction::SevereViolation),
|
|
||||||
Ok(RuntimeState::Locked)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn locked_cannot_directly_become_active() {
|
|
||||||
let err = transition_runtime(
|
|
||||||
RuntimeState::Locked,
|
|
||||||
RuntimeAction::Activate {
|
|
||||||
policy_accepted: true,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.unwrap_err();
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
err,
|
|
||||||
TransitionError::IllegalRuntimeTransition {
|
|
||||||
current: RuntimeState::Locked,
|
|
||||||
action: RuntimeAction::Activate {
|
|
||||||
policy_accepted: true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
err.to_string(),
|
|
||||||
"illegal runtime transition from Locked via Activate { policy_accepted: true }"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn admin_override_cannot_exit_to_admin_override() {
|
|
||||||
let err = transition_runtime(
|
|
||||||
RuntimeState::AdminOverride,
|
|
||||||
RuntimeAction::ExitAdminOverride(RuntimeState::AdminOverride),
|
|
||||||
)
|
|
||||||
.unwrap_err();
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
err,
|
|
||||||
TransitionError::InvalidAdminOverrideExitTarget {
|
|
||||||
target: RuntimeState::AdminOverride,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn admin_override_cannot_exit_to_active_or_transition() {
|
|
||||||
for target in [RuntimeState::Active, RuntimeState::Transition] {
|
|
||||||
let err = transition_runtime(
|
|
||||||
RuntimeState::AdminOverride,
|
|
||||||
RuntimeAction::ExitAdminOverride(target),
|
|
||||||
)
|
|
||||||
.unwrap_err();
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
err,
|
|
||||||
TransitionError::InvalidAdminOverrideExitTarget { target }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn admin_override_can_exit_to_locked_or_planning() {
|
|
||||||
assert_eq!(
|
|
||||||
transition_runtime(
|
|
||||||
RuntimeState::AdminOverride,
|
|
||||||
RuntimeAction::ExitAdminOverride(RuntimeState::Locked),
|
|
||||||
),
|
|
||||||
Ok(RuntimeState::Locked)
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
transition_runtime(
|
|
||||||
RuntimeState::AdminOverride,
|
|
||||||
RuntimeAction::ExitAdminOverride(RuntimeState::Planning),
|
|
||||||
),
|
|
||||||
Ok(RuntimeState::Planning)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn commitment_violation_recovery_is_explicit() {
|
|
||||||
assert_eq!(
|
|
||||||
transition_commitment(
|
|
||||||
CommitmentState::Violated,
|
|
||||||
CommitmentAction::RecoverExplicitly
|
|
||||||
),
|
|
||||||
Ok(CommitmentState::Active)
|
|
||||||
);
|
|
||||||
let err =
|
|
||||||
transition_commitment(CommitmentState::Violated, CommitmentAction::ReturnFromPause)
|
|
||||||
.unwrap_err();
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
err,
|
|
||||||
TransitionError::IllegalCommitmentTransition {
|
|
||||||
current: CommitmentState::Violated,
|
|
||||||
action: CommitmentAction::ReturnFromPause,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
err.to_string(),
|
|
||||||
"illegal commitment transition from Violated via ReturnFromPause"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn start_transition_moves_runtime_and_commitment_together() {
|
|
||||||
assert_eq!(
|
|
||||||
start_transition(RuntimeState::Active, CommitmentState::Active),
|
|
||||||
Ok(CompositeTransition {
|
|
||||||
runtime_state: RuntimeState::Transition,
|
|
||||||
commitment_state: CommitmentState::Paused,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn start_transition_fails_before_returning_partial_state() {
|
|
||||||
assert_eq!(
|
|
||||||
start_transition(RuntimeState::Active, CommitmentState::Draft),
|
|
||||||
Err(TransitionError::IllegalCommitmentTransition {
|
|
||||||
current: CommitmentState::Draft,
|
|
||||||
action: CommitmentAction::PauseForTransition,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
start_transition(RuntimeState::Locked, CommitmentState::Active),
|
|
||||||
Err(TransitionError::IllegalRuntimeTransition {
|
|
||||||
current: RuntimeState::Locked,
|
|
||||||
action: RuntimeAction::StartTransition,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
use super::WindowSnapshot;
|
|
||||||
use crate::domain::EvidenceHealth;
|
|
||||||
use regex::Regex;
|
|
||||||
use std::{process::Command, process::Output, str};
|
|
||||||
|
|
||||||
pub fn get_title_clean() -> String {
|
|
||||||
get_snapshot().title
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_snapshot() -> WindowSnapshot {
|
|
||||||
let window_info = get_window_info();
|
|
||||||
let re = Regex::new(r"-?\d+([:.]\d+)+%?").unwrap();
|
|
||||||
let title = re.replace_all(&window_info.title, "").to_string();
|
|
||||||
let class = (window_info.class != "none").then_some(window_info.class);
|
|
||||||
let health = if window_info.wid.is_empty() {
|
|
||||||
EvidenceHealth::Unavailable("xdotool active window unavailable".to_string())
|
|
||||||
} else {
|
|
||||||
EvidenceHealth::Available
|
|
||||||
};
|
|
||||||
|
|
||||||
WindowSnapshot {
|
|
||||||
title,
|
|
||||||
class,
|
|
||||||
health,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn minimize_other(title: &str) {
|
|
||||||
let window_info = get_window_info();
|
|
||||||
if window_info.wid.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if window_info.title != title {
|
|
||||||
run_xdotool(&["windowminimize", &window_info.wid]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct WindowInfo {
|
|
||||||
title: String,
|
|
||||||
class: String,
|
|
||||||
wid: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_valid_window_id(wid: &str) -> bool {
|
|
||||||
!wid.is_empty() && wid.bytes().all(|byte| byte.is_ascii_digit())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn run_xdotool(args: &[&str]) -> Option<String> {
|
|
||||||
let output = Command::new("xdotool").args(args).output();
|
|
||||||
|
|
||||||
let Ok(Output {
|
|
||||||
status,
|
|
||||||
stdout,
|
|
||||||
stderr: _,
|
|
||||||
}) = output
|
|
||||||
else {
|
|
||||||
return None;
|
|
||||||
};
|
|
||||||
|
|
||||||
if status.code() != Some(0) {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
let Ok(output_str) = str::from_utf8(&stdout) else {
|
|
||||||
return None;
|
|
||||||
};
|
|
||||||
|
|
||||||
Some(output_str.trim().to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_window_info() -> WindowInfo {
|
|
||||||
let none = WindowInfo {
|
|
||||||
title: "none".to_string(),
|
|
||||||
class: "none".to_string(),
|
|
||||||
wid: "".to_string(),
|
|
||||||
};
|
|
||||||
|
|
||||||
let Some(wid) = run_xdotool(&["getactivewindow"]) else {
|
|
||||||
return none;
|
|
||||||
};
|
|
||||||
|
|
||||||
if !is_valid_window_id(&wid) {
|
|
||||||
return none;
|
|
||||||
};
|
|
||||||
|
|
||||||
let Some(class) = run_xdotool(&["getwindowclassname", &wid]) else {
|
|
||||||
return none;
|
|
||||||
};
|
|
||||||
|
|
||||||
let Some(title) = run_xdotool(&["getwindowname", &wid]) else {
|
|
||||||
return none;
|
|
||||||
};
|
|
||||||
|
|
||||||
WindowInfo { title, class, wid }
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn valid_window_id_accepts_ascii_digits_only() {
|
|
||||||
assert!(is_valid_window_id("12345"));
|
|
||||||
assert!(is_valid_window_id("0"));
|
|
||||||
|
|
||||||
assert!(!is_valid_window_id(""));
|
|
||||||
assert!(!is_valid_window_id("12abc"));
|
|
||||||
assert!(!is_valid_window_id(" 123"));
|
|
||||||
assert!(!is_valid_window_id("123 "));
|
|
||||||
assert!(!is_valid_window_id("123"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
use crate::domain::EvidenceHealth;
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
||||||
pub struct WindowSnapshot {
|
|
||||||
pub title: String,
|
|
||||||
pub class: Option<String>,
|
|
||||||
pub health: EvidenceHealth,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl WindowSnapshot {
|
|
||||||
pub fn unavailable(reason: impl Into<String>) -> Self {
|
|
||||||
Self {
|
|
||||||
title: "none".to_string(),
|
|
||||||
class: None,
|
|
||||||
health: EvidenceHealth::Unavailable(reason.into()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
mod windows;
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
pub use windows::*;
|
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
mod linux;
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
pub use linux::*;
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn unavailable_snapshot_records_reason_without_evidence() {
|
|
||||||
assert_eq!(
|
|
||||||
WindowSnapshot::unavailable("no active window"),
|
|
||||||
WindowSnapshot {
|
|
||||||
title: "none".to_string(),
|
|
||||||
class: None,
|
|
||||||
health: EvidenceHealth::Unavailable("no active window".to_string()),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
use super::WindowSnapshot;
|
|
||||||
use crate::domain::EvidenceHealth;
|
|
||||||
use regex::Regex;
|
|
||||||
use std::{ffi::OsString, os::windows::ffi::OsStringExt};
|
|
||||||
use winapi::shared::windef::HWND;
|
|
||||||
use winapi::um::winuser::{GetForegroundWindow, GetWindowTextW, ShowWindow, SW_MINIMIZE};
|
|
||||||
|
|
||||||
pub fn get_title_clean() -> String {
|
|
||||||
get_snapshot().title
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_snapshot() -> WindowSnapshot {
|
|
||||||
let window_info = get_window_info();
|
|
||||||
if window_info.hwnd.is_null() {
|
|
||||||
return WindowSnapshot::unavailable("foreground window unavailable");
|
|
||||||
}
|
|
||||||
|
|
||||||
let re = Regex::new(r"-?\d+([:.]\d+)+%?").unwrap();
|
|
||||||
let title = re.replace_all(&window_info.title, "").to_string();
|
|
||||||
let health = if window_info.title.is_empty() {
|
|
||||||
EvidenceHealth::Degraded("foreground window title unavailable".to_string())
|
|
||||||
} else {
|
|
||||||
EvidenceHealth::Degraded("window class unavailable on current Windows adapter".to_string())
|
|
||||||
};
|
|
||||||
|
|
||||||
WindowSnapshot {
|
|
||||||
title,
|
|
||||||
class: None,
|
|
||||||
health,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn minimize_other(title: &str) {
|
|
||||||
let window_info = get_window_info();
|
|
||||||
if window_info.hwnd.is_null() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if window_info.title != title {
|
|
||||||
unsafe {
|
|
||||||
ShowWindow(window_info.hwnd, SW_MINIMIZE);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct WindowInfo {
|
|
||||||
title: String,
|
|
||||||
hwnd: HWND,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_window_info() -> WindowInfo {
|
|
||||||
unsafe {
|
|
||||||
let hwnd = GetForegroundWindow();
|
|
||||||
if hwnd.is_null() {
|
|
||||||
return WindowInfo {
|
|
||||||
title: String::new(),
|
|
||||||
hwnd,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut text: [u16; 512] = [0; 512];
|
|
||||||
let len = GetWindowTextW(hwnd, text.as_mut_ptr(), text.len() as i32) as usize;
|
|
||||||
let title = OsString::from_wide(&text[..len])
|
|
||||||
.to_string_lossy()
|
|
||||||
.into_owned();
|
|
||||||
WindowInfo { title, hwnd }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user