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:
parent
a881e6703f
commit
3af4d5d398
556 changed files with 56609 additions and 21892 deletions
|
|
@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
505
crates/codegen/xai-grok-config/src/managed_text/format.rs
Normal file
505
crates/codegen/xai-grok-config/src/managed_text/format.rs
Normal 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,
|
||||
§ion,
|
||||
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
|
||||
}
|
||||
258
crates/codegen/xai-grok-config/src/managed_text/mod.rs
Normal file
258
crates/codegen/xai-grok-config/src/managed_text/mod.rs
Normal 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;
|
||||
421
crates/codegen/xai-grok-config/src/managed_text/source.rs
Normal file
421
crates/codegen/xai-grok-config/src/managed_text/source.rs
Normal 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(¤t) {
|
||||
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(¤t) {
|
||||
Ok(metadata) if metadata.file_type().is_symlink() => {
|
||||
followed = true;
|
||||
let link = fs::read_link(¤t).map_err(|source| ManagedConfigError::Read {
|
||||
path: current.clone(),
|
||||
source,
|
||||
})?;
|
||||
current = if link.is_absolute() {
|
||||
normalize_lexically(&link)
|
||||
} else {
|
||||
normalize_lexically(
|
||||
¤t
|
||||
.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
|
||||
}
|
||||
632
crates/codegen/xai-grok-config/src/managed_text/tests.rs
Normal file
632
crates/codegen/xai-grok-config/src/managed_text/tests.rs
Normal 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");
|
||||
}
|
||||
454
crates/codegen/xai-grok-config/src/managed_text/transaction.rs
Normal file
454
crates/codegen/xai-grok-config/src/managed_text/transaction.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
244
crates/codegen/xai-grok-config/src/managed_text/validator.rs
Normal file
244
crates/codegen/xai-grok-config/src/managed_text/validator.rs
Normal 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));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue