Synced from monorepo

Synced from monorepo

Changes:
- Shell: accept target response id on rewind execute
- Shell: stamp response id on chat user message chunks
- Worktree: optional rebuild and stale git registration cleanup in auto-GC
- Worktree: kind-aware auto-GC TTLs and config knobs
- Worktree: macOS process CWD scan and Unix PID liveness for GC guards
- Worktree: automatic throttled GC on startup (Linux age-based; non-Linux dead-only)
- Pager: add `[ui].combine_queued_prompts` to batch queued follow-ups
- Shell: stop overwriting user skills
- Tools: read markdown in `skills/` directories untruncated
- `/usage` shows per-session token and dollar usage in the TUI
- Security: prompt on environment-dumping `ps` variants
- Security: always-safe `kubectl` no longer runs arbitrary kubeconfig credential plugins without permission
- Tools: make scheduler deletion durable
- Shell: add relocation storage primitives
- Shell: give side model calls their own conversation ids
- Fix five workflow-runtime bugs (budget, pause, cancel, reconnect)
- Security: peel `env -S` / `--split-string` operands in the Bash permission gate (managed deny/ask)
- Pager: expose doctor in the TUI
- Security: block unauthorized RCE via abused safe commands
- Pager idle watcher cue: "1 subagent still running" instead of "watching · 1 subagent"
- Security: block `rg --pre` arbitrary code execution in auto-mode
- Voice: diagnose silent-mic failures (macOS permission) and add doctor/terminal-setup Voice section
- App builder deployer: `allow_forking` and `show_built_with_grok`
- Pager: stop stacking duplicate "Worked for" markers on parked turns
- Shell: support `max` as a distinct reasoning effort tier
- Tools: serialize background `/loop` fires on the whole work unit
- Shell: add working-directory relocation state primitives
- Proto: `ClientToolResult` and `ChatConfig` client-side tools
- Shell: model providers
- Chat: select App Builder product on the Build path
- Shell: attach author identity to feedback when the deployment opts in
- Doctor: fix for SSH wrap setup
- Workflow authoring skills: create-workflow and import-claude-workflow docs
- Add read-only grok doctor
- Sandbox: apply Landlock without a controlling TTY
- Pager: recover image paste over grok wrap on headless remotes
- Pager: make actions screen-mode aware
- Shell: resume sessions when the working directory moves
- Pager: centralize terminal diagnostics
- Workspace: gate inline shell file access
- Pager: centralize terminal probes
- Pager: edit minimal prompts in an external editor
- Pager: standardize backgrounding on Ctrl+B
- Shell: recap rides the parent turn's prompt cache
- Tools: add scheduler lifecycle version clock

Source-Revision: 0f4d7c91b8b2b408333f6de1e8a76cb8eaa71899
This commit is contained in:
grokkybara[bot] 2026-07-21 18:10:23 +00:00
commit 3af4d5d398
556 changed files with 56609 additions and 21892 deletions

View file

@ -76,8 +76,14 @@ pub fn patch_touches_any(patch: &toml::Table, paths: &[PatchPath]) -> bool {
}
/// Keys stripped from every applied patch: an override cannot re-inject nested
/// `version_overrides`/`campaigns` or define `[auth_provider.*]` command tables.
pub const PATCH_STRIP_KEYS: &[&str] = &["version_overrides", "campaigns", "auth_provider"];
/// `version_overrides`/`campaigns` or define `[auth_provider.*]` /
/// `[model_providers.*]` command tables.
pub const PATCH_STRIP_KEYS: &[&str] = &[
"version_overrides",
"campaigns",
"auth_provider",
"model_providers",
];
/// Deep-merge each patch in iteration order (later wins on a leaf), stripping
/// `strip_keys` (top level) first.
@ -135,24 +141,35 @@ mod tests {
"auth_provider".into(),
toml::Value::Table(toml::Table::new()),
);
p.insert(
"model_providers".into(),
toml::Value::Table(toml::Table::new()),
);
p.insert("keep".into(), toml::Value::Boolean(true));
apply_patches(&mut cfg2, std::iter::once(p), PATCH_STRIP_KEYS);
assert!(cfg2.get("version_overrides").is_none());
assert!(cfg2.get("campaigns").is_none());
assert!(cfg2.get("auth_provider").is_none());
assert!(cfg2.get("model_providers").is_none());
assert_eq!(cfg2["keep"].as_bool(), Some(true));
// Top-level strip only: a model may still reference a local provider by name.
let mut cfg3 = toml::Value::Table(toml::Table::new());
let p = table(
"[auth_provider.injected]\ncommand = \"evil\"\n\
[model.x]\nauth_provider = \"local-name\"\n",
[model_providers.injected]\nbase_url = \"https://evil.example/v1\"\n\
[model.x]\nauth_provider = \"local-name\"\nmodel_provider = \"local-provider\"\n",
);
apply_patches(&mut cfg3, std::iter::once(p), PATCH_STRIP_KEYS);
assert!(cfg3.get("auth_provider").is_none());
assert!(cfg3.get("model_providers").is_none());
assert_eq!(
cfg3["model"]["x"]["auth_provider"].as_str(),
Some("local-name")
);
assert_eq!(
cfg3["model"]["x"]["model_provider"].as_str(),
Some("local-provider")
);
}
}

View file

@ -19,6 +19,7 @@ pub mod fs_atomic;
mod loader;
mod macos_managed;
mod managed_cache;
pub mod managed_text;
mod paths;
pub mod shell;
pub mod signed_policy;

View file

@ -0,0 +1,505 @@
use std::collections::{HashMap, HashSet};
use std::path::Path;
use super::{ManagedConfigError, ManagedConfigRequest, ManagedItem};
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct CommentSyntax {
pub(super) prefix: String,
}
impl CommentSyntax {
pub fn new(prefix: impl Into<String>) -> Result<Self, ManagedConfigError> {
let prefix = prefix.into();
if prefix.is_empty() || prefix.contains(['\r', '\n']) {
return Err(ManagedConfigError::InvalidRequest(
"comment prefix must be one non-empty line".to_owned(),
));
}
Ok(Self { prefix })
}
pub fn hash() -> Self {
Self {
prefix: "#".to_owned(),
}
}
}
pub(super) struct RenderedUpdate {
pub updated: String,
pub unmanaged_text: String,
}
pub(super) fn validate_request(request: &ManagedConfigRequest) -> Result<(), ManagedConfigError> {
validate_name(&request.namespace, "namespace")?;
validate_name(&request.owned_item_prefix, "owned item prefix")?;
if request.items.is_empty() {
return Err(ManagedConfigError::InvalidRequest(
"at least one managed item is required".to_owned(),
));
}
let mut names = HashSet::new();
for item in &request.items {
validate_name(&item.name, "item name")?;
if !names.insert(&item.name) {
return Err(ManagedConfigError::InvalidRequest(format!(
"duplicate requested item {}",
item.name
)));
}
if item.body.contains('\r') {
return Err(ManagedConfigError::InvalidRequest(format!(
"item {} contains a carriage return",
item.name
)));
}
if item
.body
.lines()
.any(|line| marker_candidate(line, &request.comments.prefix).is_some())
{
return Err(ManagedConfigError::InvalidRequest(format!(
"item {} contains marker-like content",
item.name
)));
}
}
Ok(())
}
fn validate_name(name: &str, label: &str) -> Result<(), ManagedConfigError> {
if name.is_empty()
|| !name
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-' | b' '))
{
return Err(ManagedConfigError::InvalidRequest(format!(
"{label} contains unsupported characters"
)));
}
Ok(())
}
pub(super) fn outer_block(
text: &str,
namespace: &str,
owned_item_prefix: &str,
comments: &CommentSyntax,
path: &Path,
) -> Result<Option<String>, ManagedConfigError> {
let parsed = parse_block(text, namespace, owned_item_prefix, comments, path)?;
Ok(parsed
.outer_range
.map(|(start, end)| text[start..end].trim_end_matches(['\r', '\n']).to_owned()))
}
pub(super) fn render_update(
original: &str,
namespace: &str,
owned_item_prefix: &str,
items: &[ManagedItem],
comments: &CommentSyntax,
path: &Path,
) -> Result<RenderedUpdate, ManagedConfigError> {
let initial = parse_block(original, namespace, owned_item_prefix, comments, path)?;
let unmanaged_text = initial.unmanaged_text(original);
let mut updated = original.to_owned();
for item in items {
let parsed = parse_block(&updated, namespace, owned_item_prefix, comments, path)?;
let section = item_section(item, comments, parsed.newline);
updated = if let Some(range) = parsed.items.get(&item.name) {
let keep_eol = updated[range.start..range.end].ends_with('\n');
let replacement = if keep_eol {
format!("{section}{}", parsed.newline.as_str())
} else {
section
};
replace_range(&updated, range.start, range.end, &replacement)
} else if let Some(close) = parsed.outer_close {
let insertion = format!("{section}{}", parsed.newline.as_str());
replace_range(&updated, close.start, close.start, &insertion)
} else {
append_outer(
&updated,
namespace,
&section,
comments,
parsed.newline,
parsed.final_newline,
)
};
}
parse_block(&updated, namespace, owned_item_prefix, comments, path)?;
Ok(RenderedUpdate {
updated,
unmanaged_text,
})
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum Newline {
Lf,
CrLf,
}
impl Newline {
fn as_str(self) -> &'static str {
match self {
Self::Lf => "\n",
Self::CrLf => "\r\n",
}
}
}
#[derive(Clone, Debug)]
struct Line {
start: usize,
content_end: usize,
end: usize,
}
impl Line {
fn content<'a>(&self, text: &'a str) -> &'a str {
&text[self.start..self.content_end]
}
}
#[derive(Clone, Debug)]
struct ItemRange {
start: usize,
end: usize,
}
#[derive(Clone, Debug)]
struct ParsedBlock {
newline: Newline,
final_newline: bool,
outer_range: Option<(usize, usize)>,
outer_close: Option<Line>,
items: HashMap<String, ItemRange>,
}
impl ParsedBlock {
fn unmanaged_text(&self, text: &str) -> String {
let Some((start, end)) = self.outer_range else {
return text.to_owned();
};
let mut unmanaged = String::with_capacity(text.len() - (end - start));
unmanaged.push_str(&text[..start]);
unmanaged.push_str(&text[end..]);
unmanaged
}
}
fn replace_range(text: &str, start: usize, end: usize, replacement: &str) -> String {
let mut result = String::with_capacity(text.len() - (end - start) + replacement.len());
result.push_str(&text[..start]);
result.push_str(replacement);
result.push_str(&text[end..]);
result
}
fn append_outer(
original: &str,
namespace: &str,
section: &str,
comments: &CommentSyntax,
newline: Newline,
final_newline: bool,
) -> String {
let eol = newline.as_str();
let block = format!(
"{} >>> {} >>>{eol}{section}{eol}{} <<< {} <<<",
comments.prefix, namespace, comments.prefix, namespace
);
if original.is_empty() {
return block;
}
if final_newline {
format!("{original}{block}{eol}")
} else {
format!("{original}{eol}{block}")
}
}
fn item_section(item: &ManagedItem, comments: &CommentSyntax, newline: Newline) -> String {
let eol = newline.as_str();
let body = item.body.trim_end_matches('\n').replace('\n', eol);
format!(
"{} >>> {} >>>{eol}{body}{eol}{} <<< {} <<<",
comments.prefix, item.name, comments.prefix, item.name
)
}
fn parse_block(
text: &str,
namespace: &str,
owned_item_prefix: &str,
comments: &CommentSyntax,
path: &Path,
) -> Result<ParsedBlock, ManagedConfigError> {
let newline = detect_newline(text).map_err(|reason| ManagedConfigError::InvalidMarkers {
path: path.to_path_buf(),
reason,
})?;
let lines = lines(text);
let final_newline = text.ends_with('\n');
let outer_open_text = format!("{} >>> {} >>>", comments.prefix, namespace);
let outer_close_text = format!("{} <<< {} <<<", comments.prefix, namespace);
for line in &lines {
let content = line.content(text);
let Some(candidate) = marker_candidate(content, &comments.prefix) else {
continue;
};
let owns_marker = candidate.contains(namespace) || candidate.contains(owned_item_prefix);
if owns_marker
&& content != outer_open_text
&& content != outer_close_text
&& parse_marker(content, &comments.prefix).is_none()
{
return Err(ManagedConfigError::InvalidMarkers {
path: path.to_path_buf(),
reason: format!("malformed owned marker `{content}`"),
});
}
}
let opens = lines
.iter()
.filter(|line| line.content(text) == outer_open_text)
.cloned()
.collect::<Vec<_>>();
let closes = lines
.iter()
.filter(|line| line.content(text) == outer_close_text)
.cloned()
.collect::<Vec<_>>();
if opens.len() != closes.len() || opens.len() > 1 {
return Err(ManagedConfigError::InvalidMarkers {
path: path.to_path_buf(),
reason: "duplicate or unmatched outer markers".to_owned(),
});
}
let (open, close) = match (opens.first(), closes.first()) {
(None, None) => {
reject_owned_markers_outside(text, &lines, None, owned_item_prefix, comments, path)?;
return Ok(ParsedBlock {
newline,
final_newline,
outer_range: None,
outer_close: None,
items: HashMap::new(),
});
}
(Some(open), Some(close)) if open.start < close.start => (open.clone(), close.clone()),
(Some(_), Some(_)) => {
return Err(ManagedConfigError::InvalidMarkers {
path: path.to_path_buf(),
reason: "outer markers are reversed".to_owned(),
});
}
_ => unreachable!("outer marker counts were checked"),
};
reject_owned_markers_outside(
text,
&lines,
Some((open.start, close.end)),
owned_item_prefix,
comments,
path,
)?;
let mut items = HashMap::new();
let mut active: Option<(String, Line)> = None;
for line in lines
.iter()
.filter(|line| line.start > open.start && line.start < close.start)
{
let content = line.content(text);
if content.trim().is_empty() && active.is_none() {
continue;
}
let Some((direction, name)) = parse_marker(content, &comments.prefix) else {
if active.is_none() {
return Err(ManagedConfigError::InvalidMarkers {
path: path.to_path_buf(),
reason: "content outside a named item section".to_owned(),
});
}
continue;
};
match (direction, active.take()) {
(MarkerDirection::Open, None)
if name != namespace && name.starts_with(owned_item_prefix) =>
{
active = Some((name, line.clone()));
}
(MarkerDirection::Open, None) => {
return Err(ManagedConfigError::InvalidMarkers {
path: path.to_path_buf(),
reason: format!("unowned item marker {name} inside managed block"),
});
}
(MarkerDirection::Open, _) => {
return Err(ManagedConfigError::InvalidMarkers {
path: path.to_path_buf(),
reason: "nested or duplicate item opening marker".to_owned(),
});
}
(MarkerDirection::Close, Some((open_name, open))) if open_name == name => {
if items
.insert(
name.clone(),
ItemRange {
start: open.start,
end: line.end,
},
)
.is_some()
{
return Err(ManagedConfigError::InvalidMarkers {
path: path.to_path_buf(),
reason: format!("duplicate item section {name}"),
});
}
}
(MarkerDirection::Close, Some(_)) => {
return Err(ManagedConfigError::InvalidMarkers {
path: path.to_path_buf(),
reason: format!("reversed or mismatched item marker {name}"),
});
}
(MarkerDirection::Close, None) => {
return Err(ManagedConfigError::InvalidMarkers {
path: path.to_path_buf(),
reason: format!("unmatched item closing marker {name}"),
});
}
}
}
if let Some((name, _)) = active {
return Err(ManagedConfigError::InvalidMarkers {
path: path.to_path_buf(),
reason: format!("unmatched item opening marker {name}"),
});
}
Ok(ParsedBlock {
newline,
final_newline,
outer_range: Some((open.start, close.end)),
outer_close: Some(close),
items,
})
}
fn reject_owned_markers_outside(
text: &str,
lines: &[Line],
outer: Option<(usize, usize)>,
owned_item_prefix: &str,
comments: &CommentSyntax,
path: &Path,
) -> Result<(), ManagedConfigError> {
for line in lines {
if outer.is_some_and(|(start, end)| line.start >= start && line.start < end) {
continue;
}
if let Some((_, name)) = parse_marker(line.content(text), &comments.prefix)
&& name.starts_with(owned_item_prefix)
{
return Err(ManagedConfigError::InvalidMarkers {
path: path.to_path_buf(),
reason: format!("owned item marker {name} appears outside the outer block"),
});
}
}
Ok(())
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum MarkerDirection {
Open,
Close,
}
fn marker_candidate<'a>(line: &'a str, prefix: &str) -> Option<&'a str> {
let rest = line.strip_prefix(prefix)?;
let marker = rest.trim_start_matches([' ', '\t']);
if marker.len() == rest.len() {
return None;
}
(marker.starts_with(">>>") || marker.starts_with("<<<")).then_some(marker)
}
fn parse_marker(line: &str, prefix: &str) -> Option<(MarkerDirection, String)> {
marker_candidate(line, prefix)?;
let open_prefix = format!("{prefix} >>> ");
if let Some(name) = line
.strip_prefix(&open_prefix)
.and_then(|rest| rest.strip_suffix(" >>>"))
.filter(|name| !name.is_empty())
{
return Some((MarkerDirection::Open, name.to_owned()));
}
let close_prefix = format!("{prefix} <<< ");
line.strip_prefix(&close_prefix)
.and_then(|rest| rest.strip_suffix(" <<<"))
.filter(|name| !name.is_empty())
.map(|name| (MarkerDirection::Close, name.to_owned()))
}
fn detect_newline(text: &str) -> Result<Newline, String> {
let bytes = text.as_bytes();
let mut saw_lf = false;
let mut saw_crlf = false;
for (index, byte) in bytes.iter().enumerate() {
if *byte == b'\r' && bytes.get(index + 1) != Some(&b'\n') {
return Err("bare carriage return in config".to_owned());
}
if *byte == b'\n' {
if index > 0 && bytes[index - 1] == b'\r' {
saw_crlf = true;
} else {
saw_lf = true;
}
}
}
match (saw_lf, saw_crlf) {
(true, true) => Err("mixed line endings in config".to_owned()),
(false, true) => Ok(Newline::CrLf),
_ => Ok(Newline::Lf),
}
}
fn lines(text: &str) -> Vec<Line> {
let bytes = text.as_bytes();
let mut result = Vec::new();
let mut start = 0;
for (index, byte) in bytes.iter().enumerate() {
if *byte == b'\n' {
let content_end = if index > start && bytes[index - 1] == b'\r' {
index - 1
} else {
index
};
result.push(Line {
start,
content_end,
end: index + 1,
});
start = index + 1;
}
}
if start < text.len() {
result.push(Line {
start,
content_end: text.len(),
end: text.len(),
});
}
result
}

View file

@ -0,0 +1,258 @@
//! Item-addressable edits for marked blocks in line-comment config files.
//!
//! Structured formats such as TOML keep their native editors.
use std::path::{Path, PathBuf};
mod format;
mod source;
mod transaction;
mod validator;
pub use format::CommentSyntax;
pub use validator::SyntaxValidator;
use source::{ParentPlan, SourceState};
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ManagedItem {
pub name: String,
pub body: String,
}
impl ManagedItem {
pub fn new(name: impl Into<String>, body: impl Into<String>) -> Self {
Self {
name: name.into(),
body: body.into(),
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ManagedConfigRequest {
pub path: PathBuf,
pub namespace: String,
/// Prefix identifying every item marker owned by this writer namespace.
pub owned_item_prefix: String,
pub items: Vec<ManagedItem>,
pub comments: CommentSyntax,
pub validator: Option<SyntaxValidator>,
}
/// Validated source from the snapshot used to build the plan.
#[derive(Clone, Debug)]
pub struct ManagedTextInspection {
original_text: Option<String>,
unmanaged_text: String,
}
impl ManagedTextInspection {
pub fn original_text(&self) -> Option<&str> {
self.original_text.as_deref()
}
/// Source outside the writer-owned outer block.
pub fn unmanaged_text(&self) -> &str {
&self.unmanaged_text
}
}
/// Immutable source and output state presented before application.
#[derive(Clone, Debug)]
pub struct ManagedConfigPlan {
request: ManagedConfigRequest,
requested_path: PathBuf,
target_path: PathBuf,
parent_plan: ParentPlan,
original: SourceState,
inspection: ManagedTextInspection,
updated: Vec<u8>,
backup_path_hint: Option<PathBuf>,
temp_path_hint: Option<PathBuf>,
lock_path: PathBuf,
}
impl ManagedConfigPlan {
pub fn requested_path(&self) -> &Path {
&self.requested_path
}
pub fn target_path(&self) -> &Path {
&self.target_path
}
pub fn inspection(&self) -> &ManagedTextInspection {
&self.inspection
}
pub fn updated_bytes(&self) -> &[u8] {
&self.updated
}
/// Exact complete managed outer block as it will appear after apply.
pub fn managed_block(&self) -> Option<String> {
format::outer_block(
std::str::from_utf8(&self.updated).ok()?,
&self.request.namespace,
&self.request.owned_item_prefix,
&self.request.comments,
&self.target_path,
)
.ok()
.flatten()
}
/// Proposed backup path shown during confirmation. Apply first tries this
/// exact path, then atomically retries nearby names if it was claimed in
/// the meantime. [`ManagedConfigOutcome::backup_path`] is authoritative.
pub fn backup_path_hint(&self) -> Option<&Path> {
self.backup_path_hint.as_deref()
}
pub fn changes_file(&self) -> bool {
self.original.bytes.as_deref() != Some(self.updated.as_slice())
|| self.original.bytes.is_none()
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum ManagedConfigStatus {
Applied,
NoChange,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ManagedConfigOutcome {
pub status: ManagedConfigStatus,
pub requested_path: PathBuf,
pub target_path: PathBuf,
/// Actual collision-free backup path retained for an applied change.
pub backup_path: Option<PathBuf>,
}
#[derive(Debug, thiserror::Error)]
pub enum ManagedConfigError {
#[error("invalid managed-config request: {0}")]
InvalidRequest(String),
#[error("refusing unsafe config path {path}: {reason}")]
UnsafePath { path: PathBuf, reason: String },
#[error("could not read config {path}: {source}")]
Read {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("invalid managed markers in {path}: {reason}")]
InvalidMarkers { path: PathBuf, reason: String },
#[error("config changed after confirmation; run the fix again: {0}")]
StalePlan(PathBuf),
#[error("config parent changed after confirmation; run the fix again: {0}")]
ParentChanged(PathBuf),
#[error("could not lock config transaction {path}: {source}")]
Lock {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("could not write config artifact {path}: {source}")]
Write {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("syntax validation failed for {path}: {reason}")]
Validation { path: PathBuf, reason: String },
#[error("could not atomically publish {path}: {source}")]
Publish {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("could not sync config directory {path}: {source}")]
Sync {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("post-write verification failed for {path}: {reason}")]
Verification { path: PathBuf, reason: String },
#[error("transaction failed: {primary}; recovery also failed: {recovery}")]
Recovery {
primary: Box<ManagedConfigError>,
recovery: Box<ManagedConfigError>,
},
#[error("transaction phase {phase} failed: {source}")]
Phase {
phase: &'static str,
#[source]
source: std::io::Error,
},
}
pub struct ManagedConfig;
impl ManagedConfig {
pub fn plan(request: ManagedConfigRequest) -> Result<ManagedConfigPlan, ManagedConfigError> {
format::validate_request(&request)?;
let requested_path = source::absolute_lexical(&request.path)?;
let target_path = source::resolve_final_symlink(&requested_path)?;
let parent = target_path
.parent()
.ok_or_else(|| ManagedConfigError::UnsafePath {
path: target_path.clone(),
reason: "target has no parent directory".to_owned(),
})?;
let parent_plan = ParentPlan::capture(parent)?;
let original = source::read_source(&target_path)?;
let text = original.text(&target_path)?;
let rendered = format::render_update(
text,
&request.namespace,
&request.owned_item_prefix,
&request.items,
&request.comments,
&target_path,
)?;
let inspection = ManagedTextInspection {
original_text: original.bytes.as_ref().map(|_| text.to_owned()),
unmanaged_text: rendered.unmanaged_text,
};
let updated = rendered.updated.into_bytes();
let changes =
original.bytes.as_deref() != Some(updated.as_slice()) || original.bytes.is_none();
let backup_path_hint = (changes && original.bytes.is_some())
.then(|| transaction::artifact_hint(&target_path, "grok-backup"));
let temp_path_hint = changes.then(|| transaction::artifact_hint(&target_path, "grok-tmp"));
let lock_path = transaction::sibling_artifact(&target_path, "grok.lock");
Ok(ManagedConfigPlan {
request,
requested_path,
target_path,
parent_plan,
original,
inspection,
updated,
backup_path_hint,
temp_path_hint,
lock_path,
})
}
pub fn apply(plan: ManagedConfigPlan) -> Result<ManagedConfigOutcome, ManagedConfigError> {
transaction::apply(plan, &transaction::NoopObserver)
}
#[cfg(test)]
fn apply_with_observer(
plan: ManagedConfigPlan,
observer: &dyn transaction::TransactionObserver,
) -> Result<ManagedConfigOutcome, ManagedConfigError> {
transaction::apply(plan, observer)
}
}
#[cfg(test)]
#[path = "tests.rs"]
mod tests;

View file

@ -0,0 +1,421 @@
use std::collections::HashSet;
use std::fs;
use std::path::{Component, Path, PathBuf};
use super::{ManagedConfigError, ManagedConfigPlan};
pub(super) const MAX_SYMLINKS: usize = 40;
pub(super) const MAX_CONFIG_BYTES: u64 = 4 * 1024 * 1024;
#[derive(Clone, Debug, Eq, PartialEq)]
pub(super) struct SourceState {
pub bytes: Option<Vec<u8>>,
pub hash: String,
pub mode: Option<u32>,
pub identity: Option<FileIdentity>,
}
impl SourceState {
pub fn text<'a>(&'a self, path: &Path) -> Result<&'a str, ManagedConfigError> {
match self.bytes.as_deref() {
Some(bytes) => std::str::from_utf8(bytes).map_err(|_| ManagedConfigError::UnsafePath {
path: path.to_path_buf(),
reason: "file is not valid UTF-8".to_owned(),
}),
None => Ok(""),
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub(super) struct ParentPlan {
parent: PathBuf,
existing_chain: Vec<PathIdentity>,
first_missing: Option<PathBuf>,
}
impl ParentPlan {
pub fn capture(parent: &Path) -> Result<Self, ManagedConfigError> {
let mut chain = Vec::new();
let mut current = PathBuf::new();
let mut first_missing = None;
for component in parent.components() {
current.push(component.as_os_str());
if matches!(component, Component::Prefix(_) | Component::RootDir) {
continue;
}
match fs::symlink_metadata(&current) {
Ok(metadata) => {
if metadata.file_type().is_symlink() {
return Err(ManagedConfigError::UnsafePath {
path: current,
reason: "symlinked parent directory is not allowed".to_owned(),
});
}
if !metadata.is_dir() {
return Err(ManagedConfigError::UnsafePath {
path: current,
reason: "parent component is not a directory".to_owned(),
});
}
chain.push(PathIdentity {
path: current.clone(),
identity: FileIdentity::from_metadata(&metadata),
});
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
first_missing = Some(current.clone());
break;
}
Err(source) => {
return Err(ManagedConfigError::Read {
path: current,
source,
});
}
}
}
Ok(Self {
parent: parent.to_path_buf(),
existing_chain: chain,
first_missing,
})
}
pub fn ensure_and_anchor(&self) -> Result<ParentAnchor, ManagedConfigError> {
self.revalidate_existing()?;
fs::create_dir_all(&self.parent).map_err(|source| ManagedConfigError::Write {
path: self.parent.clone(),
source,
})?;
self.revalidate_existing()?;
let current = Self::capture(&self.parent)?;
if current.first_missing.is_some()
|| !current.existing_chain.starts_with(&self.existing_chain)
{
return Err(ManagedConfigError::ParentChanged(self.parent.clone()));
}
ParentAnchor::capture(&self.parent)
}
pub fn revalidate_planned(&self) -> Result<(), ManagedConfigError> {
self.revalidate_existing()?;
if self.first_missing.is_none() {
let current = Self::capture(&self.parent)?;
if current.existing_chain != self.existing_chain {
return Err(ManagedConfigError::ParentChanged(self.parent.clone()));
}
}
Ok(())
}
fn revalidate_existing(&self) -> Result<(), ManagedConfigError> {
for expected in &self.existing_chain {
let metadata = fs::symlink_metadata(&expected.path)
.map_err(|_| ManagedConfigError::ParentChanged(expected.path.clone()))?;
if metadata.file_type().is_symlink()
|| !metadata.is_dir()
|| FileIdentity::from_metadata(&metadata) != expected.identity
{
return Err(ManagedConfigError::ParentChanged(expected.path.clone()));
}
}
Ok(())
}
}
#[derive(Debug)]
pub(super) struct ParentAnchor {
path: PathBuf,
identity: FileIdentity,
directory: fs::File,
}
impl ParentAnchor {
fn capture(path: &Path) -> Result<Self, ManagedConfigError> {
let metadata = fs::symlink_metadata(path).map_err(|source| ManagedConfigError::Read {
path: path.to_path_buf(),
source,
})?;
if metadata.file_type().is_symlink() || !metadata.is_dir() {
return Err(ManagedConfigError::ParentChanged(path.to_path_buf()));
}
let directory = fs::File::open(path).map_err(|source| ManagedConfigError::Read {
path: path.to_path_buf(),
source,
})?;
Ok(Self {
path: path.to_path_buf(),
identity: FileIdentity::from_metadata(&metadata),
directory,
})
}
pub fn revalidate(&self) -> Result<(), ManagedConfigError> {
let current = Self::capture(&self.path)?;
if current.identity != self.identity {
return Err(ManagedConfigError::ParentChanged(self.path.clone()));
}
Ok(())
}
pub fn sync(&self) -> Result<(), ManagedConfigError> {
#[cfg(unix)]
{
self.directory
.sync_all()
.map_err(|source| ManagedConfigError::Sync {
path: self.path.clone(),
source,
})
}
#[cfg(not(unix))]
{
Ok(())
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
struct PathIdentity {
path: PathBuf,
identity: FileIdentity,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(super) struct FileIdentity {
#[cfg(unix)]
dev: u64,
#[cfg(unix)]
ino: u64,
#[cfg(not(unix))]
len: u64,
#[cfg(not(unix))]
modified: Option<std::time::SystemTime>,
}
impl FileIdentity {
fn from_metadata(metadata: &fs::Metadata) -> Self {
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt as _;
Self {
dev: metadata.dev(),
ino: metadata.ino(),
}
}
#[cfg(not(unix))]
{
Self {
len: metadata.len(),
modified: metadata.modified().ok(),
}
}
}
}
pub(super) fn absolute_lexical(path: &Path) -> Result<PathBuf, ManagedConfigError> {
let absolute = if path.is_absolute() {
path.to_path_buf()
} else {
std::env::current_dir()
.map_err(|source| ManagedConfigError::Read {
path: path.to_path_buf(),
source,
})?
.join(path)
};
Ok(normalize_lexically(&absolute))
}
fn normalize_lexically(path: &Path) -> PathBuf {
let mut normalized = PathBuf::new();
for component in path.components() {
match component {
Component::CurDir => {}
Component::ParentDir => {
normalized.pop();
}
other => normalized.push(other.as_os_str()),
}
}
normalized
}
pub(super) fn resolve_final_symlink(path: &Path) -> Result<PathBuf, ManagedConfigError> {
let mut current = physicalize_parent(path)?;
let mut followed = false;
let mut seen = HashSet::new();
for _ in 0..MAX_SYMLINKS {
if !seen.insert(current.clone()) {
return Err(ManagedConfigError::UnsafePath {
path: path.to_path_buf(),
reason: "symlink cycle detected".to_owned(),
});
}
match fs::symlink_metadata(&current) {
Ok(metadata) if metadata.file_type().is_symlink() => {
followed = true;
let link = fs::read_link(&current).map_err(|source| ManagedConfigError::Read {
path: current.clone(),
source,
})?;
current = if link.is_absolute() {
normalize_lexically(&link)
} else {
normalize_lexically(
&current
.parent()
.unwrap_or_else(|| Path::new("/"))
.join(link),
)
};
}
Ok(_) => return Ok(current),
Err(error) if error.kind() == std::io::ErrorKind::NotFound && !followed => {
return Ok(current);
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return Err(ManagedConfigError::UnsafePath {
path: path.to_path_buf(),
reason: "symlink target does not exist".to_owned(),
});
}
Err(source) => {
return Err(ManagedConfigError::Read {
path: current,
source,
});
}
}
}
Err(ManagedConfigError::UnsafePath {
path: path.to_path_buf(),
reason: format!("symlink chain exceeds {MAX_SYMLINKS} links"),
})
}
fn physicalize_parent(path: &Path) -> Result<PathBuf, ManagedConfigError> {
let Some(parent) = path.parent() else {
return Ok(path.to_path_buf());
};
let mut probe = parent;
let mut missing = Vec::new();
loop {
match dunce::canonicalize(probe) {
Ok(canonical) => {
let mut physical = canonical;
for component in missing.iter().rev() {
physical.push(component);
}
if let Some(name) = path.file_name() {
physical.push(name);
}
return Ok(physical);
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
let name = probe
.file_name()
.ok_or_else(|| ManagedConfigError::UnsafePath {
path: path.to_path_buf(),
reason: "could not resolve config parent".to_owned(),
})?;
missing.push(name.to_os_string());
probe = probe
.parent()
.ok_or_else(|| ManagedConfigError::UnsafePath {
path: path.to_path_buf(),
reason: "could not resolve config parent".to_owned(),
})?;
}
Err(source) => {
return Err(ManagedConfigError::Read {
path: probe.to_path_buf(),
source,
});
}
}
}
}
pub(super) fn read_source(path: &Path) -> Result<SourceState, ManagedConfigError> {
let metadata = match fs::metadata(path) {
Ok(metadata) => metadata,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return Ok(SourceState {
bytes: None,
hash: blake3::hash(&[]).to_hex().to_string(),
mode: default_mode(),
identity: None,
});
}
Err(source) => {
return Err(ManagedConfigError::Read {
path: path.to_path_buf(),
source,
});
}
};
if !metadata.file_type().is_file() {
return Err(ManagedConfigError::UnsafePath {
path: path.to_path_buf(),
reason: "target is not a regular file".to_owned(),
});
}
if metadata.len() > MAX_CONFIG_BYTES {
return Err(ManagedConfigError::UnsafePath {
path: path.to_path_buf(),
reason: format!("file exceeds {MAX_CONFIG_BYTES} bytes"),
});
}
let bytes = fs::read(path).map_err(|source| ManagedConfigError::Read {
path: path.to_path_buf(),
source,
})?;
if bytes.contains(&0) {
return Err(ManagedConfigError::UnsafePath {
path: path.to_path_buf(),
reason: "file contains NUL bytes".to_owned(),
});
}
Ok(SourceState {
hash: blake3::hash(&bytes).to_hex().to_string(),
bytes: Some(bytes),
mode: file_mode(&metadata),
identity: Some(FileIdentity::from_metadata(&metadata)),
})
}
pub(super) fn revalidate(plan: &ManagedConfigPlan) -> Result<(), ManagedConfigError> {
plan.parent_plan.revalidate_planned()?;
let target = resolve_final_symlink(&plan.requested_path)?;
if target != plan.target_path {
return Err(ManagedConfigError::StalePlan(plan.requested_path.clone()));
}
let current = read_source(&target)?;
if current != plan.original {
return Err(ManagedConfigError::StalePlan(plan.requested_path.clone()));
}
Ok(())
}
#[cfg(unix)]
fn file_mode(metadata: &fs::Metadata) -> Option<u32> {
use std::os::unix::fs::PermissionsExt as _;
Some(metadata.permissions().mode() & 0o7777)
}
#[cfg(not(unix))]
fn file_mode(_: &fs::Metadata) -> Option<u32> {
None
}
#[cfg(unix)]
fn default_mode() -> Option<u32> {
Some(0o644)
}
#[cfg(not(unix))]
fn default_mode() -> Option<u32> {
None
}

View file

@ -0,0 +1,632 @@
use std::collections::HashSet;
use std::fs;
use std::io;
use std::path::Path;
use std::sync::{Arc, Barrier, Mutex};
use std::time::{Duration, Instant};
use super::transaction::{TransactionObserver, TransactionPhase};
use super::*;
fn request(path: &Path, items: &[(&str, &str)]) -> ManagedConfigRequest {
ManagedConfigRequest {
path: path.to_path_buf(),
namespace: "grok doctor".to_owned(),
owned_item_prefix: "terminal.".to_owned(),
items: items
.iter()
.map(|(name, body)| {
let name = if name.starts_with("terminal.") {
(*name).to_owned()
} else {
format!("terminal.{name}")
};
ManagedItem::new(name, *body)
})
.collect(),
comments: CommentSyntax::hash(),
validator: None,
}
}
fn expected(body: &str, newline: &str) -> String {
[
"# >>> grok doctor >>>",
"# >>> terminal.ssh-wrap >>>",
body,
"# <<< terminal.ssh-wrap <<<",
"# <<< grok doctor <<<",
]
.join(newline)
}
fn artifacts(directory: &Path) -> HashSet<String> {
fs::read_dir(directory)
.unwrap()
.map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())
.filter(|name| name.contains(".grok-"))
.collect()
}
#[test]
fn missing_empty_normal_no_final_newline_and_crlf_are_preserved() {
let temp = tempfile::tempdir().unwrap();
let missing = temp.path().join("missing.rc");
let plan = ManagedConfig::plan(request(
&missing,
&[("terminal.ssh-wrap", "alias ssh='grok wrap ssh'")],
))
.unwrap();
assert_eq!(
plan.updated_bytes(),
expected("alias ssh='grok wrap ssh'", "\n").as_bytes()
);
assert!(plan.backup_path_hint().is_none());
ManagedConfig::apply(plan).unwrap();
assert_eq!(
fs::read_to_string(&missing).unwrap(),
expected("alias ssh='grok wrap ssh'", "\n")
);
let empty = temp.path().join("empty.rc");
fs::write(&empty, "").unwrap();
let plan = ManagedConfig::plan(request(
&empty,
&[("terminal.ssh-wrap", "alias ssh='grok wrap ssh'")],
))
.unwrap();
assert!(plan.backup_path_hint().is_some());
ManagedConfig::apply(plan).unwrap();
let normal = temp.path().join("normal.rc");
fs::write(&normal, "export KEEP=1\n").unwrap();
let plan = ManagedConfig::plan(request(
&normal,
&[("terminal.ssh-wrap", "alias ssh='grok wrap ssh'")],
))
.unwrap();
assert_eq!(
String::from_utf8(plan.updated_bytes().to_vec()).unwrap(),
format!(
"export KEEP=1\n{}\n",
expected("alias ssh='grok wrap ssh'", "\n")
)
);
let no_final = temp.path().join("no-final.rc");
fs::write(&no_final, "export KEEP=1").unwrap();
let plan = ManagedConfig::plan(request(&no_final, &[("item", "body")])).unwrap();
assert!(
!String::from_utf8(plan.updated_bytes().to_vec())
.unwrap()
.ends_with('\n')
);
let crlf = temp.path().join("crlf.rc");
fs::write(&crlf, b"set -x KEEP 1\r\n").unwrap();
let plan = ManagedConfig::plan(request(&crlf, &[("item", "body")])).unwrap();
let rendered = String::from_utf8(plan.updated_bytes().to_vec()).unwrap();
assert!(!rendered.replace("\r\n", "").contains('\n'));
}
#[test]
fn typed_inspection_and_item_updates_share_one_validated_parse() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("config.rc");
fs::write(
&path,
"before\n# >>> grok doctor >>>\n# >>> terminal.old >>>\nold\n# <<< terminal.old <<<\n# <<< grok doctor <<<\nafter\n",
)
.unwrap();
let plan = ManagedConfig::plan(request(&path, &[("new", "new body")])).unwrap();
let original = fs::read_to_string(&path).unwrap();
assert_eq!(plan.inspection().original_text(), Some(original.as_str()));
assert_eq!(plan.inspection().unmanaged_text(), "before\nafter\n");
let block = plan.managed_block().unwrap();
assert!(block.contains("# >>> terminal.old >>>\nold\n# <<< terminal.old <<<"));
assert!(block.contains("# >>> terminal.new >>>\nnew body\n# <<< terminal.new <<<"));
ManagedConfig::apply(plan).unwrap();
let plan = ManagedConfig::plan(request(&path, &[("old", "replaced")])).unwrap();
let rendered = String::from_utf8(plan.updated_bytes().to_vec()).unwrap();
assert!(rendered.contains("# >>> terminal.old >>>\nreplaced\n# <<< terminal.old <<<"));
assert!(rendered.starts_with("before\n"));
assert!(rendered.ends_with("after\n"));
}
#[test]
fn prose_and_exports_with_owned_words_and_chevrons_are_inert() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("inert");
let content = [
"# Terminal.app note: terminal. support >>> may vary <<< by host",
"# grok doctor docs say >>> run this later <<<",
"export NOTE='terminal.ssh-wrap >>> not a marker'",
"printf '%s\\n' 'grok doctor <<< prose >>>'",
"#terminal.future prose >>> lacks marker grammar",
"echo '# >>> terminal.future >>> embedded text'",
]
.join("\n");
fs::write(&path, &content).unwrap();
let plan = ManagedConfig::plan(request(&path, &[("terminal.current", "body")])).unwrap();
assert!(String::from_utf8_lossy(plan.updated_bytes()).starts_with(&content));
}
#[test]
fn malformed_structural_owned_near_markers_are_rejected() {
let temp = tempfile::tempdir().unwrap();
for (index, content) in [
"# >>> terminal.future >>\n",
"# <<< terminal.future <<\n",
"# >>> terminal.future >> extra\n",
"#\t<<< terminal.future <<< extra\n",
"# >>> grok doctor >>\n",
]
.iter()
.enumerate()
{
let path = temp.path().join(format!("near-{index}"));
fs::write(&path, content).unwrap();
assert!(matches!(
ManagedConfig::plan(request(&path, &[("terminal.current", "body")])),
Err(ManagedConfigError::InvalidMarkers { .. })
));
}
}
#[test]
fn owned_future_markers_are_rejected_independent_of_requested_items() {
let temp = tempfile::tempdir().unwrap();
for (index, content) in [
"# >>> terminal.future >>>\nbody\n# <<< terminal.future <<<\n",
"# >>> grok doctor >>>\n# >>> terminal.current >>>\nbody\n# <<< terminal.current <<<\n# <<< grok doctor <<<\n# >>> terminal.future >>>\nbody\n# <<< terminal.future <<<\n",
]
.iter()
.enumerate()
{
let path = temp.path().join(format!("future-{index}"));
fs::write(&path, content).unwrap();
assert!(matches!(
ManagedConfig::plan(request(&path, &[("terminal.current", "body")])),
Err(ManagedConfigError::InvalidMarkers { .. })
));
}
let unrelated = temp.path().join("unrelated");
fs::write(
&unrelated,
"# >>> user custom >>>\nnot ours\n# <<< user custom <<<\n",
)
.unwrap();
assert!(ManagedConfig::plan(request(&unrelated, &[("terminal.current", "body")])).is_ok());
}
#[test]
fn exact_noop_creates_no_transaction_artifacts_or_rewrite() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("config.rc");
let content = expected("body", "\n");
fs::write(&path, &content).unwrap();
let before = fs::metadata(&path).unwrap().modified().unwrap();
let outcome = ManagedConfig::apply(
ManagedConfig::plan(request(&path, &[("terminal.ssh-wrap", "body")])).unwrap(),
)
.unwrap();
assert_eq!(outcome.status, ManagedConfigStatus::NoChange);
assert_eq!(fs::read_to_string(&path).unwrap(), content);
assert_eq!(fs::metadata(&path).unwrap().modified().unwrap(), before);
assert!(artifacts(temp.path()).is_empty());
}
#[test]
fn invalid_inputs_and_all_marker_shapes_are_refused() {
let temp = tempfile::tempdir().unwrap();
let oversize = temp.path().join("oversize");
fs::write(
&oversize,
vec![b'x'; super::source::MAX_CONFIG_BYTES as usize + 1],
)
.unwrap();
let nul = temp.path().join("nul");
fs::write(&nul, b"a\0b").unwrap();
let non_utf8 = temp.path().join("non-utf8");
fs::write(&non_utf8, [0xff]).unwrap();
for path in [&oversize, &nul, &non_utf8] {
assert!(matches!(
ManagedConfig::plan(request(path, &[("item", "body")])),
Err(ManagedConfigError::UnsafePath { .. })
));
}
let cases = [
"# >>> grok doctor >>>\n",
"# <<< grok doctor <<<\n# >>> grok doctor >>>\n",
"# >>> grok doctor >>\n",
"# >>> grok doctor >>>\nraw\n# <<< grok doctor <<<\n",
"# >>> grok doctor >>>\n# <<< terminal.item <<<\n# <<< grok doctor <<<\n",
"# >>> grok doctor >>>\n# >>> terminal.item >>>\nbody\n# <<< terminal.other <<<\n# <<< grok doctor <<<\n",
"# >>> grok doctor >>>\n# >>> terminal.item >>>\nbody\n# <<< terminal.item <<<\n# >>> terminal.item >>>\nbody\n# <<< terminal.item <<<\n# <<< grok doctor <<<\n",
"# >>> terminal.item >>>\nbody\n# <<< terminal.item <<<\n",
];
for (index, content) in cases.iter().enumerate() {
let path = temp.path().join(format!("marker-{index}"));
fs::write(&path, content).unwrap();
assert!(matches!(
ManagedConfig::plan(request(&path, &[("item", "new")])),
Err(ManagedConfigError::InvalidMarkers { .. })
));
}
}
#[cfg(unix)]
#[test]
fn symlink_resolution_depth_cycles_and_parent_symlinks_are_refused() {
use std::os::unix::fs::symlink;
let temp = tempfile::tempdir().unwrap();
let physical = temp.path().join("physical");
fs::write(&physical, "keep\n").unwrap();
let relative = temp.path().join("relative");
symlink("physical", &relative).unwrap();
let plan = ManagedConfig::plan(request(&relative, &[("item", "body")])).unwrap();
assert_eq!(
plan.target_path(),
fs::canonicalize(&physical).unwrap().as_path()
);
ManagedConfig::apply(plan).unwrap();
assert!(
fs::symlink_metadata(&relative)
.unwrap()
.file_type()
.is_symlink()
);
let cycle_a = temp.path().join("cycle-a");
let cycle_b = temp.path().join("cycle-b");
symlink("cycle-b", &cycle_a).unwrap();
symlink("cycle-a", &cycle_b).unwrap();
assert!(ManagedConfig::plan(request(&cycle_a, &[("item", "body")])).is_err());
let mut last = temp.path().join("depth-target");
fs::write(&last, "body").unwrap();
for index in 0..=super::source::MAX_SYMLINKS {
let next = temp.path().join(format!("depth-{index}"));
symlink(&last, &next).unwrap();
last = next;
}
assert!(ManagedConfig::plan(request(&last, &[("item", "body")])).is_err());
let real_parent = temp.path().join("real-parent");
fs::create_dir(&real_parent).unwrap();
let linked_parent = temp.path().join("linked-parent");
symlink(&real_parent, &linked_parent).unwrap();
let plan =
ManagedConfig::plan(request(&linked_parent.join("rc"), &[("item", "body")])).unwrap();
assert_eq!(
plan.target_path().parent(),
Some(fs::canonicalize(&real_parent).unwrap().as_path())
);
}
#[cfg(unix)]
#[test]
fn bytes_mode_and_actual_backup_are_exact() {
use std::os::unix::fs::PermissionsExt as _;
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("config.rc");
let original = b"export KEEP=1\r\n";
fs::write(&path, original).unwrap();
fs::set_permissions(&path, fs::Permissions::from_mode(0o640)).unwrap();
let plan = ManagedConfig::plan(request(&path, &[("item", "body")])).unwrap();
let hint = plan.backup_path_hint().unwrap().to_path_buf();
let outcome = ManagedConfig::apply(plan).unwrap();
let backup = outcome.backup_path.unwrap();
assert_eq!(backup, hint);
assert_eq!(fs::read(&backup).unwrap(), original);
assert_eq!(
fs::metadata(&backup).unwrap().permissions().mode() & 0o777,
0o640
);
assert_eq!(
fs::metadata(&path).unwrap().permissions().mode() & 0o777,
0o640
);
}
#[test]
fn stale_source_and_parent_swap_are_rejected_before_publication() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("parent/config.rc");
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(&path, "before\n").unwrap();
let plan = ManagedConfig::plan(request(&path, &[("item", "body")])).unwrap();
fs::write(&path, "changed\n").unwrap();
assert!(matches!(
ManagedConfig::apply(plan),
Err(ManagedConfigError::StalePlan(_))
));
let plan = ManagedConfig::plan(request(&path, &[("item", "body")])).unwrap();
let old_parent = temp.path().join("old-parent");
fs::rename(path.parent().unwrap(), &old_parent).unwrap();
fs::create_dir(path.parent().unwrap()).unwrap();
assert!(matches!(
ManagedConfig::apply(plan),
Err(ManagedConfigError::ParentChanged(_))
));
assert!(!path.exists());
}
#[cfg(unix)]
#[test]
fn missing_parent_revalidation_rejects_new_symlink_component() {
use std::os::unix::fs::symlink;
let temp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(temp.path()).unwrap();
let path = root.join("missing/child/config.rc");
let parent_plan = super::source::ParentPlan::capture(path.parent().unwrap()).unwrap();
let target = root.join("redirected");
fs::create_dir(&target).unwrap();
fs::create_dir(root.join("missing")).unwrap();
symlink(&target, root.join("missing/child")).unwrap();
assert!(matches!(
parent_plan.ensure_and_anchor(),
Err(ManagedConfigError::UnsafePath { .. })
));
}
#[test]
fn backup_and_temp_hint_collisions_retry_under_lock() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("config.rc");
fs::write(&path, "original\n").unwrap();
let plan = ManagedConfig::plan(request(&path, &[("item", "body")])).unwrap();
let backup_hint = plan.backup_path_hint().unwrap().to_path_buf();
let temp_hint = plan.temp_path_hint.as_ref().unwrap().clone();
fs::write(&backup_hint, "unrelated backup").unwrap();
fs::write(&temp_hint, "unrelated temp").unwrap();
let outcome = ManagedConfig::apply(plan).unwrap();
assert_ne!(outcome.backup_path.as_deref(), Some(backup_hint.as_path()));
assert_eq!(
fs::read_to_string(&backup_hint).unwrap(),
"unrelated backup"
);
assert_eq!(fs::read_to_string(&temp_hint).unwrap(), "unrelated temp");
}
struct FailAt(TransactionPhase);
impl TransactionObserver for FailAt {
fn phase(&self, phase: TransactionPhase, _: &ManagedConfigPlan) -> io::Result<()> {
if phase == self.0 {
Err(io::Error::other(format!("injected {}", phase.name())))
} else {
Ok(())
}
}
}
struct CorruptTemp;
impl TransactionObserver for CorruptTemp {
fn mutate_written_temp(&self, path: &Path, _: &ManagedConfigPlan) -> io::Result<()> {
fs::write(path, "corrupt")
}
}
#[test]
fn all_precommit_phase_failures_cleanup_and_preserve_original() {
let phases = [
TransactionPhase::BeforeBackupReserve,
TransactionPhase::AfterBackupReserved,
TransactionPhase::BeforeTempReserve,
TransactionPhase::BeforeTempWrite,
TransactionPhase::AfterTempWritten,
TransactionPhase::AfterValidation,
TransactionPhase::BeforePublish,
];
for phase in phases {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("config.rc");
fs::write(&path, "original\n").unwrap();
let plan = ManagedConfig::plan(request(&path, &[("item", "body")])).unwrap();
assert!(matches!(
ManagedConfig::apply_with_observer(plan, &FailAt(phase)),
Err(ManagedConfigError::Phase { .. })
));
assert_eq!(fs::read_to_string(&path).unwrap(), "original\n");
assert!(artifacts(temp.path()).is_empty());
}
}
#[test]
fn post_publish_failures_rollback_existing_and_remove_new_target() {
let phases = [
TransactionPhase::AfterPublish,
TransactionPhase::BeforeParentSync,
TransactionPhase::AfterParentSync,
TransactionPhase::BeforeVerify,
];
for phase in phases {
for existing in [false, true] {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("config.rc");
if existing {
fs::write(&path, "original\n").unwrap();
}
let plan = ManagedConfig::plan(request(&path, &[("item", "body")])).unwrap();
assert!(ManagedConfig::apply_with_observer(plan, &FailAt(phase)).is_err());
if existing {
assert_eq!(fs::read_to_string(&path).unwrap(), "original\n");
} else {
assert!(!path.exists());
}
assert!(artifacts(temp.path()).is_empty());
}
}
}
#[test]
fn verification_failure_rolls_back_exact_original() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("config.rc");
fs::write(&path, "original\n").unwrap();
let plan = ManagedConfig::plan(request(&path, &[("item", "body")])).unwrap();
let error = ManagedConfig::apply_with_observer(plan, &CorruptTemp).unwrap_err();
assert!(matches!(error, ManagedConfigError::Verification { .. }));
assert_eq!(fs::read_to_string(&path).unwrap(), "original\n");
assert!(artifacts(temp.path()).is_empty());
}
#[test]
fn primary_and_rollback_errors_are_both_reported() {
struct FailBoth;
impl TransactionObserver for FailBoth {
fn phase(&self, phase: TransactionPhase, _: &ManagedConfigPlan) -> io::Result<()> {
if matches!(
phase,
TransactionPhase::AfterPublish | TransactionPhase::BeforeRollback
) {
Err(io::Error::other("injected failure"))
} else {
Ok(())
}
}
}
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("config.rc");
fs::write(&path, "original\n").unwrap();
let plan = ManagedConfig::plan(request(&path, &[("item", "body")])).unwrap();
assert!(matches!(
ManagedConfig::apply_with_observer(plan, &FailBoth),
Err(ManagedConfigError::Recovery { .. })
));
}
#[test]
fn publish_and_parent_sync_failures_are_injected_at_the_real_operations() {
struct PublishFailure;
impl TransactionObserver for PublishFailure {
fn publish(&self, _: &Path, _: &Path) -> io::Result<()> {
Err(io::Error::other("injected publish failure"))
}
}
struct SyncFailure;
impl TransactionObserver for SyncFailure {
fn sync_parent(
&self,
parent: &super::source::ParentAnchor,
rollback: bool,
) -> Result<(), ManagedConfigError> {
if rollback {
parent.sync()
} else {
Err(ManagedConfigError::Sync {
path: Path::new("injected-parent").to_path_buf(),
source: io::Error::other("injected sync failure"),
})
}
}
}
for observer in [
&PublishFailure as &dyn TransactionObserver,
&SyncFailure as &dyn TransactionObserver,
] {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("config.rc");
fs::write(&path, "original\n").unwrap();
let plan = ManagedConfig::plan(request(&path, &[("item", "body")])).unwrap();
assert!(ManagedConfig::apply_with_observer(plan, observer).is_err());
assert_eq!(fs::read_to_string(&path).unwrap(), "original\n");
assert!(artifacts(temp.path()).is_empty());
}
}
#[test]
fn failed_validator_cleans_reserved_backup_and_temp() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("config.rc");
fs::write(&path, "original\n").unwrap();
let mut request = request(&path, &[("item", "body")]);
request.validator = Some(SyntaxValidator {
program: "/bin/sh".into(),
args: vec!["-c".into(), "exit 7".into()],
timeout: Duration::from_secs(1),
});
assert!(matches!(
ManagedConfig::apply(ManagedConfig::plan(request).unwrap()),
Err(ManagedConfigError::Validation { .. })
));
assert_eq!(fs::read_to_string(&path).unwrap(), "original\n");
assert!(artifacts(temp.path()).is_empty());
}
#[test]
fn transaction_lock_blocks_second_apply_then_stale_revalidation_wins() {
struct BlockAfterLock {
reached: Arc<Barrier>,
release: Arc<Barrier>,
}
impl TransactionObserver for BlockAfterLock {
fn phase(&self, phase: TransactionPhase, _: &ManagedConfigPlan) -> io::Result<()> {
if phase == TransactionPhase::AfterLock {
self.reached.wait();
self.release.wait();
}
Ok(())
}
}
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("config.rc");
fs::write(&path, "original\n").unwrap();
let first = ManagedConfig::plan(request(&path, &[("one", "one")])).unwrap();
let second = ManagedConfig::plan(request(&path, &[("two", "two")])).unwrap();
let reached = Arc::new(Barrier::new(2));
let release = Arc::new(Barrier::new(2));
let observer = BlockAfterLock {
reached: reached.clone(),
release: release.clone(),
};
let first_thread =
std::thread::spawn(move || ManagedConfig::apply_with_observer(first, &observer));
reached.wait();
let result = Arc::new(Mutex::new(None));
let result_thread = result.clone();
let second_thread = std::thread::spawn(move || {
*result_thread.lock().unwrap() = Some(ManagedConfig::apply(second));
});
std::thread::sleep(Duration::from_millis(50));
assert!(
result.lock().unwrap().is_none(),
"second apply must block on lock"
);
release.wait();
assert!(first_thread.join().unwrap().is_ok());
second_thread.join().unwrap();
assert!(matches!(
result.lock().unwrap().take().unwrap(),
Err(ManagedConfigError::StalePlan(_))
));
}
#[test]
fn validator_timeout_is_bounded() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("config.rc");
fs::write(&path, "original\n").unwrap();
let mut request = request(&path, &[("item", "body")]);
request.validator = Some(SyntaxValidator {
program: "/bin/sh".into(),
args: vec!["-c".into(), "sleep 5".into()],
timeout: Duration::from_millis(20),
});
let started = Instant::now();
assert!(ManagedConfig::apply(ManagedConfig::plan(request).unwrap()).is_err());
assert!(started.elapsed() < Duration::from_secs(1));
assert_eq!(fs::read_to_string(&path).unwrap(), "original\n");
}

View file

@ -0,0 +1,454 @@
use std::fs::{self, File, OpenOptions};
use std::io::{self, Write as _};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use super::source;
use super::{ManagedConfigError, ManagedConfigOutcome, ManagedConfigPlan, ManagedConfigStatus};
static ARTIFACT_NONCE: AtomicU64 = AtomicU64::new(0);
const ARTIFACT_RESERVATION_ATTEMPTS: usize = 128;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(super) enum TransactionPhase {
AfterLock,
BeforeBackupReserve,
AfterBackupReserved,
BeforeTempReserve,
BeforeTempWrite,
AfterTempWritten,
AfterValidation,
BeforePublish,
AfterPublish,
BeforeParentSync,
AfterParentSync,
BeforeVerify,
BeforeRollback,
BeforeRollbackSync,
AfterRollback,
}
impl TransactionPhase {
pub(super) fn name(self) -> &'static str {
match self {
Self::AfterLock => "after-lock",
Self::BeforeBackupReserve => "before-backup-reserve",
Self::AfterBackupReserved => "after-backup-reserved",
Self::BeforeTempReserve => "before-temp-reserve",
Self::BeforeTempWrite => "before-temp-write",
Self::AfterTempWritten => "after-temp-written",
Self::AfterValidation => "after-validation",
Self::BeforePublish => "before-publish",
Self::AfterPublish => "after-publish",
Self::BeforeParentSync => "before-parent-sync",
Self::AfterParentSync => "after-parent-sync",
Self::BeforeVerify => "before-verify",
Self::BeforeRollback => "before-rollback",
Self::BeforeRollbackSync => "before-rollback-sync",
Self::AfterRollback => "after-rollback",
}
}
}
pub(super) trait TransactionObserver: Send + Sync {
fn phase(&self, _phase: TransactionPhase, _plan: &ManagedConfigPlan) -> std::io::Result<()> {
Ok(())
}
fn mutate_written_temp(&self, _path: &Path, _plan: &ManagedConfigPlan) -> std::io::Result<()> {
Ok(())
}
fn publish(&self, temp: &Path, target: &Path) -> std::io::Result<()> {
fs::rename(temp, target)
}
fn sync_parent(
&self,
parent: &source::ParentAnchor,
_rollback: bool,
) -> Result<(), ManagedConfigError> {
parent.sync()
}
}
pub(super) struct NoopObserver;
impl TransactionObserver for NoopObserver {}
pub(super) fn sibling_artifact(path: &Path, suffix: &str) -> PathBuf {
let name = path
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_else(|| "config".to_owned());
path.with_file_name(format!("{name}.{suffix}"))
}
pub(super) fn artifact_hint(path: &Path, kind: &str) -> PathBuf {
artifact_candidate(path, kind, 0)
}
fn artifact_candidate(path: &Path, kind: &str, attempt: usize) -> PathBuf {
let suffix = if attempt == 0 {
format!("{kind}.{}", std::process::id())
} else {
let nonce = ARTIFACT_NONCE.fetch_add(1, Ordering::Relaxed);
format!("{kind}.{}.{}", std::process::id(), nonce)
};
sibling_artifact(path, &suffix)
}
pub(super) fn apply(
plan: ManagedConfigPlan,
observer: &dyn TransactionObserver,
) -> Result<ManagedConfigOutcome, ManagedConfigError> {
let parent_anchor = plan.parent_plan.ensure_and_anchor()?;
if !plan.changes_file() {
parent_anchor.revalidate()?;
source::revalidate(&plan)?;
return Ok(ManagedConfigOutcome {
status: ManagedConfigStatus::NoChange,
requested_path: plan.requested_path,
target_path: plan.target_path,
backup_path: None,
});
}
let lock = open_lock(&plan.lock_path)?;
lock.lock().map_err(|source| ManagedConfigError::Lock {
path: plan.lock_path.clone(),
source,
})?;
observe(observer, TransactionPhase::AfterLock, &plan)?;
parent_anchor.revalidate()?;
source::revalidate(&plan)?;
let mut backup = None;
let mut temp = None;
let precommit = (|| {
if let Some(bytes) = &plan.original.bytes {
observe(observer, TransactionPhase::BeforeBackupReserve, &plan)?;
let (path, mut file) = reserve_artifact(
&plan.target_path,
"grok-backup",
plan.backup_path_hint.as_deref(),
plan.original.mode,
)?;
backup = Some(path.clone());
write_reserved(&path, &mut file, bytes, plan.original.mode)?;
observe(observer, TransactionPhase::AfterBackupReserved, &plan)?;
}
observe(observer, TransactionPhase::BeforeTempReserve, &plan)?;
let (temp_path, mut temp_file) = reserve_artifact(
&plan.target_path,
"grok-tmp",
plan.temp_path_hint.as_deref(),
plan.original.mode,
)?;
temp = Some(temp_path.clone());
observe(observer, TransactionPhase::BeforeTempWrite, &plan)?;
write_reserved(
&temp_path,
&mut temp_file,
&plan.updated,
plan.original.mode,
)?;
observe(observer, TransactionPhase::AfterTempWritten, &plan)?;
if let Some(validator) = &plan.request.validator {
super::validator::validate_temp(validator, &temp_path)?;
}
observe(observer, TransactionPhase::AfterValidation, &plan)?;
parent_anchor.revalidate()?;
source::revalidate(&plan)?;
observe(observer, TransactionPhase::BeforePublish, &plan)?;
parent_anchor.revalidate()?;
apply_exact_path_mode(&temp_path, plan.original.mode)?;
observer
.publish(&temp_path, &plan.target_path)
.map_err(|source| ManagedConfigError::Publish {
path: plan.target_path.clone(),
source,
})?;
temp = None;
Ok::<(), ManagedConfigError>(())
})();
if let Err(error) = precommit {
cleanup(temp.as_deref());
cleanup(backup.as_deref());
return Err(error);
}
let post_publish = (|| {
observe(observer, TransactionPhase::AfterPublish, &plan)?;
parent_anchor.revalidate()?;
observe(observer, TransactionPhase::BeforeParentSync, &plan)?;
observer.sync_parent(&parent_anchor, false)?;
observe(observer, TransactionPhase::AfterParentSync, &plan)?;
parent_anchor.revalidate()?;
observe(observer, TransactionPhase::BeforeVerify, &plan)?;
observer
.mutate_written_temp(&plan.target_path, &plan)
.map_err(|source| ManagedConfigError::Phase {
phase: "mutate-published-target",
source,
})?;
verify_published(&plan)?;
Ok::<(), ManagedConfigError>(())
})();
if let Err(primary) = post_publish {
match rollback(&plan, observer, &parent_anchor) {
Ok(()) => {
cleanup(backup.as_deref());
return Err(primary);
}
Err(recovery) => {
return Err(ManagedConfigError::Recovery {
primary: Box::new(primary),
recovery: Box::new(recovery),
});
}
}
}
Ok(ManagedConfigOutcome {
status: ManagedConfigStatus::Applied,
requested_path: plan.requested_path,
target_path: plan.target_path,
backup_path: backup,
})
}
fn observe(
observer: &dyn TransactionObserver,
phase: TransactionPhase,
plan: &ManagedConfigPlan,
) -> Result<(), ManagedConfigError> {
observer
.phase(phase, plan)
.map_err(|source| ManagedConfigError::Phase {
phase: phase.name(),
source,
})
}
fn reserve_artifact(
target: &Path,
kind: &str,
hint: Option<&Path>,
mode: Option<u32>,
) -> Result<(PathBuf, File), ManagedConfigError> {
for attempt in 0..ARTIFACT_RESERVATION_ATTEMPTS {
let candidate = if attempt == 0 {
hint.map(Path::to_path_buf)
.unwrap_or_else(|| artifact_candidate(target, kind, attempt))
} else {
artifact_candidate(target, kind, attempt)
};
let mut options = OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
if let Some(mode) = mode {
use std::os::unix::fs::OpenOptionsExt as _;
options.mode(mode);
}
#[cfg(not(unix))]
let _ = mode;
match options.open(&candidate) {
Ok(file) => return Ok((candidate, file)),
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
Err(source) => {
return Err(ManagedConfigError::Write {
path: candidate,
source,
});
}
}
}
Err(ManagedConfigError::Write {
path: target.to_path_buf(),
source: std::io::Error::new(
std::io::ErrorKind::AlreadyExists,
format!("could not reserve a unique {kind} artifact"),
),
})
}
fn write_reserved(
path: &Path,
file: &mut File,
bytes: &[u8],
mode: Option<u32>,
) -> Result<(), ManagedConfigError> {
file.write_all(bytes)
.and_then(|()| apply_exact_mode(file, mode))
.and_then(|()| file.sync_all())
.map_err(|source| ManagedConfigError::Write {
path: path.to_path_buf(),
source,
})
}
#[cfg(unix)]
fn apply_exact_mode(file: &File, mode: Option<u32>) -> io::Result<()> {
use std::os::unix::fs::PermissionsExt as _;
if let Some(mode) = mode {
file.set_permissions(fs::Permissions::from_mode(mode))?;
}
Ok(())
}
#[cfg(not(unix))]
fn apply_exact_mode(_: &File, _: Option<u32>) -> io::Result<()> {
Ok(())
}
#[cfg(unix)]
fn apply_exact_path_mode(path: &Path, mode: Option<u32>) -> Result<(), ManagedConfigError> {
use std::os::unix::fs::PermissionsExt as _;
if let Some(mode) = mode {
fs::set_permissions(path, fs::Permissions::from_mode(mode)).map_err(|source| {
ManagedConfigError::Write {
path: path.to_path_buf(),
source,
}
})?;
}
Ok(())
}
#[cfg(not(unix))]
fn apply_exact_path_mode(_: &Path, _: Option<u32>) -> Result<(), ManagedConfigError> {
Ok(())
}
fn verify_published(plan: &ManagedConfigPlan) -> Result<(), ManagedConfigError> {
let published = fs::read(&plan.target_path).map_err(|source| ManagedConfigError::Read {
path: plan.target_path.clone(),
source,
})?;
if published != plan.updated {
return Err(ManagedConfigError::Verification {
path: plan.target_path.clone(),
reason: "published bytes differ from the confirmed plan".to_owned(),
});
}
if current_mode(&plan.target_path)? != plan.original.mode {
return Err(ManagedConfigError::Verification {
path: plan.target_path.clone(),
reason: "published mode differs from the confirmed source mode".to_owned(),
});
}
Ok(())
}
fn rollback(
plan: &ManagedConfigPlan,
observer: &dyn TransactionObserver,
parent_anchor: &source::ParentAnchor,
) -> Result<(), ManagedConfigError> {
observe(observer, TransactionPhase::BeforeRollback, plan)?;
parent_anchor.revalidate()?;
if let Some(original) = &plan.original.bytes {
let (rollback_path, mut rollback_file) =
reserve_artifact(&plan.target_path, "grok-rollback", None, plan.original.mode)?;
if let Err(error) = write_reserved(
&rollback_path,
&mut rollback_file,
original,
plan.original.mode,
) {
cleanup(Some(&rollback_path));
return Err(error);
}
apply_exact_path_mode(&rollback_path, plan.original.mode)?;
if let Err(source) = fs::rename(&rollback_path, &plan.target_path) {
cleanup(Some(&rollback_path));
return Err(ManagedConfigError::Publish {
path: plan.target_path.clone(),
source,
});
}
} else {
match fs::remove_file(&plan.target_path) {
Ok(()) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(source) => {
return Err(ManagedConfigError::Publish {
path: plan.target_path.clone(),
source,
});
}
}
}
observe(observer, TransactionPhase::BeforeRollbackSync, plan)?;
observer.sync_parent(parent_anchor, true)?;
verify_rollback(plan)?;
observe(observer, TransactionPhase::AfterRollback, plan)
}
fn verify_rollback(plan: &ManagedConfigPlan) -> Result<(), ManagedConfigError> {
match &plan.original.bytes {
Some(original) => {
let restored =
fs::read(&plan.target_path).map_err(|source| ManagedConfigError::Read {
path: plan.target_path.clone(),
source,
})?;
if &restored != original || current_mode(&plan.target_path)? != plan.original.mode {
return Err(ManagedConfigError::Verification {
path: plan.target_path.clone(),
reason: "rollback did not restore the original bytes and mode".to_owned(),
});
}
}
None if plan.target_path.exists() => {
return Err(ManagedConfigError::Verification {
path: plan.target_path.clone(),
reason: "rollback did not remove the newly created target".to_owned(),
});
}
None => {}
}
Ok(())
}
#[cfg(unix)]
fn current_mode(path: &Path) -> Result<Option<u32>, ManagedConfigError> {
use std::os::unix::fs::PermissionsExt as _;
fs::metadata(path)
.map(|metadata| Some(metadata.permissions().mode() & 0o7777))
.map_err(|source| ManagedConfigError::Read {
path: path.to_path_buf(),
source,
})
}
#[cfg(not(unix))]
fn current_mode(_: &Path) -> Result<Option<u32>, ManagedConfigError> {
Ok(None)
}
fn open_lock(path: &Path) -> Result<File, ManagedConfigError> {
let mut options = OpenOptions::new();
options.read(true).write(true).create(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt as _;
options.mode(0o600);
}
options
.open(path)
.map_err(|source| ManagedConfigError::Lock {
path: path.to_path_buf(),
source,
})
}
fn cleanup(path: Option<&Path>) {
if let Some(path) = path {
let _ = fs::remove_file(path);
}
}

View file

@ -0,0 +1,244 @@
use std::ffi::OsString;
use std::path::{Path, PathBuf};
use std::process::{Child, Command, ExitStatus, Stdio};
use std::time::{Duration, Instant};
use super::ManagedConfigError;
/// Optional syntax checker. `path` is appended after `args`, matching the
/// `bash -n FILE`, `zsh -n FILE`, and `fish -n FILE` interfaces.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SyntaxValidator {
pub program: PathBuf,
pub args: Vec<OsString>,
pub timeout: Duration,
}
pub(super) fn validate_temp(
validator: &SyntaxValidator,
path: &Path,
) -> Result<(), ManagedConfigError> {
validate_with_ops(validator, path, &RealProcessOps)
}
trait ProcessOps {
fn attach_group(&self, child: &Child) -> Result<xai_tty_utils::ProcessGroup, std::io::Error>;
fn try_wait(&self, child: &mut Child) -> std::io::Result<Option<ExitStatus>>;
fn teardown(
&self,
child: &mut Child,
group: Option<&xai_tty_utils::ProcessGroup>,
) -> Result<(), String>;
}
struct RealProcessOps;
impl ProcessOps for RealProcessOps {
fn attach_group(&self, child: &Child) -> Result<xai_tty_utils::ProcessGroup, std::io::Error> {
let mut group = xai_tty_utils::ProcessGroup::new()?;
group.attach_std(child)?;
Ok(group)
}
fn try_wait(&self, child: &mut Child) -> std::io::Result<Option<ExitStatus>> {
child.try_wait()
}
fn teardown(
&self,
child: &mut Child,
group: Option<&xai_tty_utils::ProcessGroup>,
) -> Result<(), String> {
teardown_child(child, group)
}
}
fn validate_with_ops(
validator: &SyntaxValidator,
path: &Path,
ops: &dyn ProcessOps,
) -> Result<(), ManagedConfigError> {
let mut command = Command::new(&validator.program);
command
.args(&validator.args)
.arg(path)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.envs(xai_tty_utils::pager_env());
xai_tty_utils::detach_std_command(&mut command);
let mut child = command
.spawn()
.map_err(|source| ManagedConfigError::Validation {
path: path.to_path_buf(),
reason: format!("could not start {}: {source}", validator.program.display()),
})?;
let group = ops.attach_group(&child).ok();
let started = Instant::now();
loop {
match ops.try_wait(&mut child) {
Ok(Some(status)) if status.success() => return Ok(()),
Ok(Some(status)) => {
return Err(validation_error(
path,
format!("{} exited with {status}", validator.program.display()),
None,
));
}
Ok(None) if started.elapsed() < validator.timeout => {
std::thread::sleep(Duration::from_millis(10));
}
Ok(None) => {
let teardown = ops.teardown(&mut child, group.as_ref()).err();
return Err(validation_error(
path,
format!("timed out after {:?}", validator.timeout),
teardown,
));
}
Err(source) => {
let teardown = ops.teardown(&mut child, group.as_ref()).err();
return Err(validation_error(path, source.to_string(), teardown));
}
}
}
}
fn validation_error(path: &Path, primary: String, teardown: Option<String>) -> ManagedConfigError {
let reason = match teardown {
Some(teardown) => format!("{primary}; process teardown also failed: {teardown}"),
None => primary,
};
ManagedConfigError::Validation {
path: path.to_path_buf(),
reason,
}
}
fn teardown_child(
child: &mut Child,
group: Option<&xai_tty_utils::ProcessGroup>,
) -> Result<(), String> {
let mut errors = Vec::new();
if let Some(group) = group {
if let Err(error) = group.terminate() {
errors.push(format!("terminate group: {error}"));
}
std::thread::sleep(Duration::from_millis(50));
if let Err(error) = group.kill() {
errors.push(format!("kill group: {error}"));
}
}
if let Err(error) = child.kill()
&& error.kind() != std::io::ErrorKind::InvalidInput
{
errors.push(format!("kill child: {error}"));
}
let deadline = Instant::now() + Duration::from_secs(1);
loop {
match child.try_wait() {
Ok(Some(_)) => break,
Ok(None) if Instant::now() < deadline => {
std::thread::sleep(Duration::from_millis(10));
}
Ok(None) => {
errors.push("child did not reap within 1s".to_owned());
break;
}
Err(error) => {
errors.push(format!("reap child: {error}"));
break;
}
}
}
if errors.is_empty() {
Ok(())
} else {
Err(errors.join(", "))
}
}
#[cfg(test)]
mod tests {
use std::sync::atomic::{AtomicBool, Ordering};
use super::*;
struct InjectedOps {
attach_fails: bool,
wait_fails: bool,
teardown_called: AtomicBool,
}
impl ProcessOps for InjectedOps {
fn attach_group(
&self,
child: &Child,
) -> Result<xai_tty_utils::ProcessGroup, std::io::Error> {
if self.attach_fails {
Err(std::io::Error::other("injected attach failure"))
} else {
RealProcessOps.attach_group(child)
}
}
fn try_wait(&self, child: &mut Child) -> std::io::Result<Option<ExitStatus>> {
if self.wait_fails {
Err(std::io::Error::other("injected try_wait failure"))
} else {
child.try_wait()
}
}
fn teardown(
&self,
child: &mut Child,
group: Option<&xai_tty_utils::ProcessGroup>,
) -> Result<(), String> {
self.teardown_called.store(true, Ordering::SeqCst);
teardown_child(child, group)
}
}
#[cfg(unix)]
#[test]
fn attach_failure_falls_back_to_bounded_direct_child_teardown() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("config");
std::fs::write(&path, "body").unwrap();
let validator = SyntaxValidator {
program: "/bin/sh".into(),
args: vec!["-c".into(), "sleep 5".into()],
timeout: Duration::from_millis(20),
};
let ops = InjectedOps {
attach_fails: true,
wait_fails: false,
teardown_called: AtomicBool::new(false),
};
let started = Instant::now();
assert!(validate_with_ops(&validator, &path, &ops).is_err());
assert!(ops.teardown_called.load(Ordering::SeqCst));
assert!(started.elapsed() < Duration::from_secs(2));
}
#[cfg(unix)]
#[test]
fn try_wait_error_still_tears_down_child() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("config");
std::fs::write(&path, "body").unwrap();
let validator = SyntaxValidator {
program: "/bin/sh".into(),
args: vec!["-c".into(), "sleep 5".into()],
timeout: Duration::from_secs(1),
};
let ops = InjectedOps {
attach_fails: false,
wait_fails: true,
teardown_called: AtomicBool::new(false),
};
assert!(validate_with_ops(&validator, &path, &ops).is_err());
assert!(ops.teardown_called.load(Ordering::SeqCst));
}
}