Synced from monorepo
Synced from monorepo Changes: - Workspace server: report `/ready` as failed with dwell on hub connect failure - Refresh OIDC token for the Grok agent in the shell - ACP terminal output recorder - Cross-platform provider auth commands in the shell - Default `/resume` to Grok sessions with a hint for hidden external sessions - Resume sessions by title with `--resume` - Limit app-builder archive size - Data-driven tag labels for slash commands - Doctor fixes for tmux - Custom provider gateways and subprocess environment policy in the shell - `/tutorial` — opt-in onboarding tour of Grok Build - Soft and required CLI version checks in the shell - Privacy banner env overrides survive live settings updates - Add remote flag to override the image-edit model - Return profile fields from auth info even when the access token is expired - Add edit control on queued prompt rows - Keep fail-closed policy when clearing orphans with no team - Setting to disable the Ctrl+Space/F8 voice shortcut - Pass `--raw` to pw-record so Linux dictation works on older PipeWire - Validate git URLs when adding marketplace entries - Stop shipping stale tool-doc parameter and tool names - Re-point dashboard attach after `/fork` only when the parent was attached - Surface Grok Computer media-generation results as file-path chunks - Clear web background-task tray on kill and keep the task description - Show privacy upsell banner in agent view until acted on - Add tools-server client callback surface - Protect persistent global hook sources Source-Revision: 95d84f443eddcbed6cbfd6eed22e2eafe6b3939d
This commit is contained in:
parent
a5727c5960
commit
69f0ba880a
286 changed files with 22939 additions and 9624 deletions
569
crates/codegen/xai-grok-config/src/global_hook_sources.rs
Normal file
569
crates/codegen/xai-grok-config/src/global_hook_sources.rs
Normal file
|
|
@ -0,0 +1,569 @@
|
|||
//! Grok-owned direct global hook paths shared by shell discovery and sandbox
|
||||
//! write-deny: `$GROK_HOME/hooks`, `hooks-paths`, and absolute registry targets.
|
||||
//! Relative registry lines, project hooks, and vendor compat are out of scope.
|
||||
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum GlobalHookSourceKind {
|
||||
/// `$GROK_HOME/hooks/` (discovered + protected).
|
||||
HookDirectory,
|
||||
/// `$GROK_HOME/hooks-paths` (protected; never loaded as hook JSON).
|
||||
RegistryFile,
|
||||
/// Absolute registry target (must exist before sandbox apply).
|
||||
ConfiguredSource,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct GlobalHookSource {
|
||||
pub path: PathBuf,
|
||||
pub kind: GlobalHookSourceKind,
|
||||
}
|
||||
|
||||
impl GlobalHookSource {
|
||||
pub fn is_dir(&self) -> bool {
|
||||
match self.kind {
|
||||
GlobalHookSourceKind::HookDirectory => true,
|
||||
GlobalHookSourceKind::RegistryFile => false,
|
||||
GlobalHookSourceKind::ConfiguredSource => {
|
||||
if self.path.exists() {
|
||||
self.path.is_dir()
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// False for the registry file itself (not hook JSON / not a hook dir).
|
||||
pub fn is_discovery_source(&self) -> bool {
|
||||
!matches!(self.kind, GlobalHookSourceKind::RegistryFile)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum GlobalHookSourceError {
|
||||
#[error("cannot read hooks-paths {path}: {source}")]
|
||||
HooksPathsRead {
|
||||
path: PathBuf,
|
||||
#[source]
|
||||
source: io::Error,
|
||||
},
|
||||
#[error("symlinked GROK_HOME is not allowed under sandbox write-deny: {path}")]
|
||||
SymlinkedGrokHome { path: PathBuf },
|
||||
#[error("hook source path contains a symlink component (retargetable): {path}")]
|
||||
SymlinkedSource { path: PathBuf },
|
||||
#[error("hook JSON file has hard-link aliases (st_nlink={nlink}): {path}")]
|
||||
HardLinkedHookFile { path: PathBuf, nlink: u64 },
|
||||
#[error("hook JSON path is not a regular file: {path}")]
|
||||
InvalidHookJsonFile { path: PathBuf },
|
||||
#[error("Grok hooks directory has wrong type (expected real directory): {path}")]
|
||||
InvalidHooksDir { path: PathBuf },
|
||||
#[error("Grok hooks-paths registry has wrong type (expected real file): {path}")]
|
||||
InvalidRegistryFile { path: PathBuf },
|
||||
#[error("cannot create Grok hooks directory {path}: {source}")]
|
||||
CreateHooksDir {
|
||||
path: PathBuf,
|
||||
#[source]
|
||||
source: io::Error,
|
||||
},
|
||||
#[error("cannot create Grok hooks-paths registry {path}: {source}")]
|
||||
CreateRegistryFile {
|
||||
path: PathBuf,
|
||||
#[source]
|
||||
source: io::Error,
|
||||
},
|
||||
}
|
||||
|
||||
/// Hard-fail omits all sources. Soft `configured_error` keeps fixed slots and
|
||||
/// omits configured targets (sandbox must fail closed; discovery may log).
|
||||
#[derive(Debug)]
|
||||
pub struct ResolvedGlobalHookSources {
|
||||
pub sources: Vec<GlobalHookSource>,
|
||||
pub configured_error: Option<GlobalHookSourceError>,
|
||||
}
|
||||
|
||||
impl ResolvedGlobalHookSources {
|
||||
pub fn is_incomplete(&self) -> bool {
|
||||
self.configured_error.is_some()
|
||||
}
|
||||
|
||||
pub fn discovery_sources(&self) -> impl Iterator<Item = &GlobalHookSource> {
|
||||
self.sources.iter().filter(|s| s.is_discovery_source())
|
||||
}
|
||||
}
|
||||
|
||||
/// macOS firmlinks are not attacker-retargetable; ignore in symlink scans.
|
||||
fn is_system_firmlink(path: &Path) -> bool {
|
||||
matches!(
|
||||
path.to_str(),
|
||||
Some("/tmp")
|
||||
| Some("/var")
|
||||
| Some("/etc")
|
||||
| Some("/private/tmp")
|
||||
| Some("/private/var")
|
||||
| Some("/private/etc")
|
||||
)
|
||||
}
|
||||
|
||||
/// True if any existing path component is a retargetable symlink (firmlinks skipped).
|
||||
pub fn path_has_symlink_component(path: &Path) -> bool {
|
||||
let mut cur = PathBuf::new();
|
||||
for c in path.components() {
|
||||
cur.push(c.as_os_str());
|
||||
match std::fs::symlink_metadata(&cur) {
|
||||
Ok(m) if m.file_type().is_symlink() => {
|
||||
if is_system_firmlink(&cur) {
|
||||
continue;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn push_unique(out: &mut Vec<GlobalHookSource>, source: GlobalHookSource) {
|
||||
if !out.iter().any(|s| s.path == source.path) {
|
||||
out.push(source);
|
||||
}
|
||||
}
|
||||
|
||||
/// Existing non-symlink directory ancestors (parent-first), excluding `/`.
|
||||
pub fn existing_ancestor_chain(path: &Path) -> Vec<PathBuf> {
|
||||
let mut chain = Vec::new();
|
||||
let mut cur = path.parent().map(Path::to_path_buf);
|
||||
while let Some(p) = cur {
|
||||
if p.as_os_str().is_empty() || p == Path::new("/") {
|
||||
break;
|
||||
}
|
||||
match std::fs::symlink_metadata(&p) {
|
||||
Ok(m) if m.file_type().is_dir() && !m.file_type().is_symlink() => {
|
||||
chain.push(p.clone());
|
||||
}
|
||||
Ok(_) => break,
|
||||
Err(_) => break,
|
||||
}
|
||||
cur = p.parent().map(Path::to_path_buf);
|
||||
}
|
||||
chain
|
||||
}
|
||||
|
||||
/// Linux: `st_dev` differs from parent, or listed in mountinfo. Else false.
|
||||
pub(crate) fn is_filesystem_mountpoint(path: &Path) -> bool {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
if path == Path::new("/") {
|
||||
return true;
|
||||
}
|
||||
let Ok(meta) = std::fs::metadata(path) else {
|
||||
return false;
|
||||
};
|
||||
if let Some(parent) = path.parent()
|
||||
&& let Ok(pm) = std::fs::metadata(parent)
|
||||
&& meta.dev() != pm.dev()
|
||||
{
|
||||
return true;
|
||||
}
|
||||
let Ok(mountinfo) = std::fs::read_to_string("/proc/self/mountinfo") else {
|
||||
return false;
|
||||
};
|
||||
let path_s = path.to_string_lossy();
|
||||
for line in mountinfo.lines() {
|
||||
let Some((left, _)) = line.split_once(" - ") else {
|
||||
continue;
|
||||
};
|
||||
let fields: Vec<&str> = left.split_whitespace().collect();
|
||||
if fields.len() < 5 {
|
||||
continue;
|
||||
}
|
||||
if fields[4] == path_s.as_ref() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
let _ = path;
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Ancestors to RW self-bind so rename is EBUSY: parent→root, skip already-
|
||||
/// mounted nodes but keep pinning renameable ancestors above them (never `/`).
|
||||
pub fn ancestors_to_pin_as_mountpoints(path: &Path) -> Vec<PathBuf> {
|
||||
ancestors_to_pin_as_mountpoints_with(path, is_filesystem_mountpoint)
|
||||
}
|
||||
|
||||
pub(crate) fn ancestors_to_pin_as_mountpoints_with(
|
||||
path: &Path,
|
||||
is_mountpoint: impl Fn(&Path) -> bool,
|
||||
) -> Vec<PathBuf> {
|
||||
let mut chain = Vec::new();
|
||||
let mut cur = path.parent().map(Path::to_path_buf);
|
||||
while let Some(p) = cur {
|
||||
if p.as_os_str().is_empty() || p == Path::new("/") {
|
||||
break;
|
||||
}
|
||||
match std::fs::symlink_metadata(&p) {
|
||||
Ok(m) if m.file_type().is_dir() && !m.file_type().is_symlink() => {
|
||||
if is_mountpoint(&p) {
|
||||
cur = p.parent().map(Path::to_path_buf);
|
||||
continue;
|
||||
}
|
||||
chain.push(p.clone());
|
||||
}
|
||||
Ok(_) => break,
|
||||
Err(_) => break,
|
||||
}
|
||||
cur = p.parent().map(Path::to_path_buf);
|
||||
}
|
||||
chain
|
||||
}
|
||||
|
||||
/// Unique ancestors, rootward-first (shallowest first).
|
||||
pub fn unique_ancestors_rootward(sources: &[GlobalHookSource]) -> Vec<PathBuf> {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut all = Vec::new();
|
||||
for s in sources {
|
||||
for anc in ancestors_to_pin_as_mountpoints(&s.path) {
|
||||
if seen.insert(anc.clone()) {
|
||||
all.push(anc);
|
||||
}
|
||||
}
|
||||
}
|
||||
all.sort_by_key(|p| p.components().count());
|
||||
all
|
||||
}
|
||||
|
||||
fn require_real_dir(path: &Path) -> Result<(), GlobalHookSourceError> {
|
||||
let meta = std::fs::symlink_metadata(path).map_err(|source| {
|
||||
GlobalHookSourceError::CreateHooksDir {
|
||||
path: path.to_path_buf(),
|
||||
source,
|
||||
}
|
||||
})?;
|
||||
if meta.file_type().is_symlink() || !meta.file_type().is_dir() {
|
||||
return Err(GlobalHookSourceError::InvalidHooksDir {
|
||||
path: path.to_path_buf(),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn require_real_file(path: &Path) -> Result<(), GlobalHookSourceError> {
|
||||
let meta = std::fs::symlink_metadata(path).map_err(|source| {
|
||||
GlobalHookSourceError::CreateRegistryFile {
|
||||
path: path.to_path_buf(),
|
||||
source,
|
||||
}
|
||||
})?;
|
||||
if meta.file_type().is_symlink() || !meta.file_type().is_file() {
|
||||
return Err(GlobalHookSourceError::InvalidRegistryFile {
|
||||
path: path.to_path_buf(),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Ensure real `$GROK_HOME/hooks` dir + `hooks-paths` file (create if missing).
|
||||
/// Race-resistant create (`create_dir` / `create_new`+`O_NOFOLLOW`); never
|
||||
/// truncates an existing registry; rejects symlinks/wrong types.
|
||||
pub fn ensure_grok_hook_slots(grok_home: &Path) -> Result<(), GlobalHookSourceError> {
|
||||
if path_has_symlink_component(grok_home) {
|
||||
return Err(GlobalHookSourceError::SymlinkedGrokHome {
|
||||
path: grok_home.to_path_buf(),
|
||||
});
|
||||
}
|
||||
|
||||
match std::fs::create_dir(grok_home) {
|
||||
Ok(()) => {}
|
||||
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {}
|
||||
Err(e) if e.kind() == io::ErrorKind::NotFound => {
|
||||
std::fs::create_dir_all(grok_home).map_err(|source| {
|
||||
GlobalHookSourceError::CreateHooksDir {
|
||||
path: grok_home.to_path_buf(),
|
||||
source,
|
||||
}
|
||||
})?;
|
||||
}
|
||||
Err(source) => {
|
||||
return Err(GlobalHookSourceError::CreateHooksDir {
|
||||
path: grok_home.to_path_buf(),
|
||||
source,
|
||||
});
|
||||
}
|
||||
}
|
||||
if path_has_symlink_component(grok_home) {
|
||||
return Err(GlobalHookSourceError::SymlinkedGrokHome {
|
||||
path: grok_home.to_path_buf(),
|
||||
});
|
||||
}
|
||||
let grok_meta = std::fs::symlink_metadata(grok_home).map_err(|source| {
|
||||
GlobalHookSourceError::CreateHooksDir {
|
||||
path: grok_home.to_path_buf(),
|
||||
source,
|
||||
}
|
||||
})?;
|
||||
if grok_meta.file_type().is_symlink() || !grok_meta.file_type().is_dir() {
|
||||
return Err(GlobalHookSourceError::SymlinkedGrokHome {
|
||||
path: grok_home.to_path_buf(),
|
||||
});
|
||||
}
|
||||
|
||||
let hooks = grok_home.join("hooks");
|
||||
match std::fs::create_dir(&hooks) {
|
||||
Ok(()) => {}
|
||||
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {
|
||||
require_real_dir(&hooks)?;
|
||||
}
|
||||
Err(source) => {
|
||||
return Err(GlobalHookSourceError::CreateHooksDir {
|
||||
path: hooks,
|
||||
source,
|
||||
});
|
||||
}
|
||||
}
|
||||
require_real_dir(&hooks)?;
|
||||
if path_has_symlink_component(&hooks) {
|
||||
return Err(GlobalHookSourceError::SymlinkedSource { path: hooks });
|
||||
}
|
||||
|
||||
let registry = grok_home.join("hooks-paths");
|
||||
match open_registry_create_new(®istry) {
|
||||
Ok(f) => drop(f),
|
||||
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {
|
||||
require_real_file(®istry)?;
|
||||
}
|
||||
Err(source) => {
|
||||
return Err(GlobalHookSourceError::CreateRegistryFile {
|
||||
path: registry,
|
||||
source,
|
||||
});
|
||||
}
|
||||
}
|
||||
require_real_file(®istry)?;
|
||||
if path_has_symlink_component(®istry) {
|
||||
return Err(GlobalHookSourceError::SymlinkedSource { path: registry });
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "linux", target_os = "android"))]
|
||||
const O_NOFOLLOW: i32 = 0x20000;
|
||||
#[cfg(any(
|
||||
target_os = "macos",
|
||||
target_os = "ios",
|
||||
target_os = "freebsd",
|
||||
target_os = "openbsd",
|
||||
target_os = "netbsd"
|
||||
))]
|
||||
const O_NOFOLLOW: i32 = 0x0100;
|
||||
|
||||
fn open_registry_create_new(path: &Path) -> io::Result<std::fs::File> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.mode(0o600)
|
||||
.custom_flags(O_NOFOLLOW)
|
||||
.open(path)
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(path)
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve Grok-owned direct global hook sources (`reject_symlinks` for sandbox).
|
||||
pub fn resolve_global_hook_sources(
|
||||
grok_home: Option<&Path>,
|
||||
reject_symlinks: bool,
|
||||
) -> Result<ResolvedGlobalHookSources, GlobalHookSourceError> {
|
||||
let mut out = Vec::new();
|
||||
let mut configured_error = None;
|
||||
|
||||
if let Some(grok) = grok_home {
|
||||
if reject_symlinks && path_has_symlink_component(grok) {
|
||||
return Err(GlobalHookSourceError::SymlinkedGrokHome {
|
||||
path: grok.to_path_buf(),
|
||||
});
|
||||
}
|
||||
|
||||
let hooks = grok.join("hooks");
|
||||
let hooks_paths = grok.join("hooks-paths");
|
||||
if reject_symlinks {
|
||||
for p in [&hooks, &hooks_paths] {
|
||||
if path_has_symlink_component(p) {
|
||||
return Err(GlobalHookSourceError::SymlinkedSource { path: p.clone() });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
push_unique(
|
||||
&mut out,
|
||||
GlobalHookSource {
|
||||
path: hooks,
|
||||
kind: GlobalHookSourceKind::HookDirectory,
|
||||
},
|
||||
);
|
||||
push_unique(
|
||||
&mut out,
|
||||
GlobalHookSource {
|
||||
path: hooks_paths.clone(),
|
||||
kind: GlobalHookSourceKind::RegistryFile,
|
||||
},
|
||||
);
|
||||
|
||||
match std::fs::read_to_string(&hooks_paths) {
|
||||
Ok(content) => {
|
||||
for line in content.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let path = PathBuf::from(trimmed);
|
||||
if !path.is_absolute() {
|
||||
continue;
|
||||
}
|
||||
if reject_symlinks && path_has_symlink_component(&path) {
|
||||
return Err(GlobalHookSourceError::SymlinkedSource { path });
|
||||
}
|
||||
push_unique(
|
||||
&mut out,
|
||||
GlobalHookSource {
|
||||
path,
|
||||
kind: GlobalHookSourceKind::ConfiguredSource,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) if e.kind() == io::ErrorKind::NotFound => {}
|
||||
Err(e) => {
|
||||
configured_error = Some(GlobalHookSourceError::HooksPathsRead {
|
||||
path: hooks_paths,
|
||||
source: e,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(ResolvedGlobalHookSources {
|
||||
sources: out,
|
||||
configured_error,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn missing_configured_sources(sources: &[GlobalHookSource]) -> Vec<PathBuf> {
|
||||
sources
|
||||
.iter()
|
||||
.filter(|s| s.kind == GlobalHookSourceKind::ConfiguredSource && !s.path.exists())
|
||||
.map(|s| s.path.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Discovery filename filter: `*.json`, not hidden, not editor temps.
|
||||
pub fn is_direct_hook_json_name(name: &str) -> bool {
|
||||
if !name.ends_with(".json") || name.len() <= 5 {
|
||||
return false;
|
||||
}
|
||||
if name.starts_with('.') {
|
||||
return false;
|
||||
}
|
||||
if name.ends_with('~') || name.ends_with(".swp") || name.ends_with(".swo") {
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Immediate discovery JSON files under `dir` (sorted, non-recursive).
|
||||
pub fn list_direct_hook_json_files(dir: &Path) -> io::Result<Vec<PathBuf>> {
|
||||
let mut out = Vec::new();
|
||||
let entries = match std::fs::read_dir(dir) {
|
||||
Ok(e) => e,
|
||||
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(out),
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
for entry in entries {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
|
||||
continue;
|
||||
};
|
||||
if !is_direct_hook_json_name(name) {
|
||||
continue;
|
||||
}
|
||||
out.push(path);
|
||||
}
|
||||
out.sort();
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Regular non-symlink file with `st_nlink == 1`.
|
||||
#[cfg(unix)]
|
||||
pub fn validate_direct_hook_json_file(path: &Path) -> Result<(), GlobalHookSourceError> {
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
let meta = std::fs::symlink_metadata(path).map_err(|source| {
|
||||
GlobalHookSourceError::HooksPathsRead {
|
||||
path: path.to_path_buf(),
|
||||
source,
|
||||
}
|
||||
})?;
|
||||
if meta.file_type().is_symlink() {
|
||||
return Err(GlobalHookSourceError::SymlinkedSource {
|
||||
path: path.to_path_buf(),
|
||||
});
|
||||
}
|
||||
if !meta.file_type().is_file() {
|
||||
return Err(GlobalHookSourceError::InvalidHookJsonFile {
|
||||
path: path.to_path_buf(),
|
||||
});
|
||||
}
|
||||
if meta.nlink() != 1 {
|
||||
return Err(GlobalHookSourceError::HardLinkedHookFile {
|
||||
path: path.to_path_buf(),
|
||||
nlink: meta.nlink(),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub fn validated_hook_json_files_for_sources(
|
||||
sources: &[GlobalHookSource],
|
||||
) -> Result<Vec<PathBuf>, GlobalHookSourceError> {
|
||||
let mut files = Vec::new();
|
||||
for s in sources {
|
||||
if !s.is_dir() || !s.path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let listed = list_direct_hook_json_files(&s.path).map_err(|source| {
|
||||
GlobalHookSourceError::HooksPathsRead {
|
||||
path: s.path.clone(),
|
||||
source,
|
||||
}
|
||||
})?;
|
||||
for f in listed {
|
||||
validate_direct_hook_json_file(&f)?;
|
||||
files.push(f);
|
||||
}
|
||||
}
|
||||
files.sort();
|
||||
files.dedup();
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "global_hook_sources_tests.rs"]
|
||||
mod tests;
|
||||
282
crates/codegen/xai-grok-config/src/global_hook_sources_tests.rs
Normal file
282
crates/codegen/xai-grok-config/src/global_hook_sources_tests.rs
Normal file
|
|
@ -0,0 +1,282 @@
|
|||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn absolute_hooks_paths_only_and_fixed_slots() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let dir = tmp.path();
|
||||
let nested = dir.join("extra");
|
||||
std::fs::create_dir_all(&nested).unwrap();
|
||||
std::fs::write(
|
||||
dir.join("hooks-paths"),
|
||||
format!("{}\nrelative/x\n", nested.display()),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let resolved = resolve_global_hook_sources(Some(dir), false).unwrap();
|
||||
assert!(resolved.configured_error.is_none());
|
||||
let sources = &resolved.sources;
|
||||
assert!(
|
||||
sources.iter().any(|s| {
|
||||
s.path == dir.join("hooks") && s.kind == GlobalHookSourceKind::HookDirectory
|
||||
})
|
||||
);
|
||||
assert!(sources.iter().any(|s| {
|
||||
s.path == dir.join("hooks-paths") && s.kind == GlobalHookSourceKind::RegistryFile
|
||||
}));
|
||||
assert!(
|
||||
sources
|
||||
.iter()
|
||||
.any(|s| { s.path == nested && s.kind == GlobalHookSourceKind::ConfiguredSource })
|
||||
);
|
||||
assert!(!sources.iter().any(|s| s.path.ends_with("relative/x")));
|
||||
assert!(missing_configured_sources(sources).is_empty());
|
||||
|
||||
// Discovery must never treat the registry file as a hook source.
|
||||
let discovery: Vec<_> = resolved
|
||||
.discovery_sources()
|
||||
.map(|s| s.path.clone())
|
||||
.collect();
|
||||
assert!(!discovery.iter().any(|p| p == &dir.join("hooks-paths")));
|
||||
assert!(discovery.iter().any(|p| p == &dir.join("hooks")));
|
||||
assert!(discovery.iter().any(|p| p == &nested));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_configured_is_reported() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let dir = tmp.path();
|
||||
let missing = dir.join("nope").join("hooks");
|
||||
std::fs::write(dir.join("hooks-paths"), format!("{}\n", missing.display())).unwrap();
|
||||
let resolved = resolve_global_hook_sources(Some(dir), false).unwrap();
|
||||
assert!(resolved.configured_error.is_none());
|
||||
let miss = missing_configured_sources(&resolved.sources);
|
||||
assert!(miss.iter().any(|p| p == &missing));
|
||||
assert!(!miss.iter().any(|p| p == &dir.join("hooks")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hooks_paths_read_error_keeps_fixed_slots() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let dir = tmp.path();
|
||||
// Directory named hooks-paths → read_to_string fails with IsADirectory.
|
||||
std::fs::create_dir_all(dir.join("hooks-paths")).unwrap();
|
||||
let resolved = resolve_global_hook_sources(Some(dir), false).unwrap();
|
||||
assert!(resolved.is_incomplete());
|
||||
assert!(matches!(
|
||||
resolved.configured_error,
|
||||
Some(GlobalHookSourceError::HooksPathsRead { .. })
|
||||
));
|
||||
assert!(
|
||||
resolved.sources.iter().any(|s| {
|
||||
s.path == dir.join("hooks") && s.kind == GlobalHookSourceKind::HookDirectory
|
||||
})
|
||||
);
|
||||
assert!(resolved.sources.iter().any(|s| {
|
||||
s.path == dir.join("hooks-paths") && s.kind == GlobalHookSourceKind::RegistryFile
|
||||
}));
|
||||
assert!(
|
||||
!resolved
|
||||
.sources
|
||||
.iter()
|
||||
.any(|s| s.kind == GlobalHookSourceKind::ConfiguredSource)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(unix)]
|
||||
fn reject_symlinked_configured_source() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let dir = tmp.path();
|
||||
let real = tmp.path().join("real-hooks");
|
||||
std::fs::create_dir_all(&real).unwrap();
|
||||
let link = dir.join("link-hooks");
|
||||
std::os::unix::fs::symlink(&real, &link).unwrap();
|
||||
std::fs::write(dir.join("hooks-paths"), format!("{}\n", link.display())).unwrap();
|
||||
let err = resolve_global_hook_sources(Some(dir), true).unwrap_err();
|
||||
assert!(matches!(err, GlobalHookSourceError::SymlinkedSource { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn not_found_hooks_paths_is_ok_empty_configured() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let dir = tmp.path();
|
||||
let resolved = resolve_global_hook_sources(Some(dir), false).unwrap();
|
||||
assert!(resolved.configured_error.is_none());
|
||||
assert!(missing_configured_sources(&resolved.sources).is_empty());
|
||||
assert!(
|
||||
resolved
|
||||
.sources
|
||||
.iter()
|
||||
.any(|s| s.kind == GlobalHookSourceKind::HookDirectory)
|
||||
);
|
||||
assert!(
|
||||
resolved
|
||||
.sources
|
||||
.iter()
|
||||
.any(|s| s.kind == GlobalHookSourceKind::RegistryFile)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_creates_hooks_dir_and_empty_registry() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let dir = tmp.path().join("grok");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
ensure_grok_hook_slots(&dir).unwrap();
|
||||
let hooks = dir.join("hooks");
|
||||
let reg = dir.join("hooks-paths");
|
||||
assert!(hooks.is_dir());
|
||||
assert!(reg.is_file());
|
||||
assert_eq!(std::fs::read(®).unwrap(), b"");
|
||||
// Idempotent — does not truncate existing registry content.
|
||||
std::fs::write(®, b"/abs/extra\n").unwrap();
|
||||
ensure_grok_hook_slots(&dir).unwrap();
|
||||
assert_eq!(std::fs::read(®).unwrap(), b"/abs/extra\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(unix)]
|
||||
fn ensure_rejects_preexisting_symlink_hooks_dir() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let dir = tmp.path().join("grok");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let real = tmp.path().join("real-hooks");
|
||||
std::fs::create_dir_all(&real).unwrap();
|
||||
std::os::unix::fs::symlink(&real, dir.join("hooks")).unwrap();
|
||||
let err = ensure_grok_hook_slots(&dir).unwrap_err();
|
||||
assert!(matches!(
|
||||
err,
|
||||
GlobalHookSourceError::InvalidHooksDir { .. }
|
||||
| GlobalHookSourceError::SymlinkedSource { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(unix)]
|
||||
fn ensure_rejects_preexisting_symlink_registry() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let dir = tmp.path().join("grok");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let target = tmp.path().join("evil-registry");
|
||||
std::fs::write(&target, b"attacker\n").unwrap();
|
||||
std::os::unix::fs::symlink(&target, dir.join("hooks-paths")).unwrap();
|
||||
let err = ensure_grok_hook_slots(&dir).unwrap_err();
|
||||
// create_new hits EEXIST on the symlink → require_real_file rejects it;
|
||||
// or O_NOFOLLOW path — never write through the symlink.
|
||||
assert!(matches!(
|
||||
err,
|
||||
GlobalHookSourceError::InvalidRegistryFile { .. }
|
||||
| GlobalHookSourceError::SymlinkedSource { .. }
|
||||
| GlobalHookSourceError::CreateRegistryFile { .. }
|
||||
));
|
||||
// Attacker target must remain unchanged (no write-through).
|
||||
assert_eq!(std::fs::read(&target).unwrap(), b"attacker\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(unix)]
|
||||
fn ensure_rejects_directory_named_hooks_paths() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let dir = tmp.path().join("grok");
|
||||
std::fs::create_dir_all(dir.join("hooks-paths")).unwrap();
|
||||
let err = ensure_grok_hook_slots(&dir).unwrap_err();
|
||||
assert!(matches!(
|
||||
err,
|
||||
GlobalHookSourceError::InvalidRegistryFile { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(unix)]
|
||||
fn ensure_rejects_file_named_hooks_dir() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let dir = tmp.path().join("grok");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("hooks"), b"not-a-dir").unwrap();
|
||||
let err = ensure_grok_hook_slots(&dir).unwrap_err();
|
||||
assert!(matches!(err, GlobalHookSourceError::InvalidHooksDir { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn existing_ancestor_chain_lists_parents() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let leaf = tmp.path().join("a").join("b").join("c");
|
||||
std::fs::create_dir_all(&leaf).unwrap();
|
||||
let chain = existing_ancestor_chain(&leaf);
|
||||
assert_eq!(chain[0], tmp.path().join("a").join("b"));
|
||||
assert!(chain.iter().any(|p| p == &tmp.path().join("a")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_direct_hook_json_files_matches_discovery_filter() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let dir = tmp.path();
|
||||
std::fs::write(dir.join("active.json"), b"{}").unwrap();
|
||||
std::fs::write(dir.join(".hidden.json"), b"{}").unwrap();
|
||||
std::fs::write(dir.join("backup.json~"), b"{}").unwrap();
|
||||
std::fs::write(dir.join("notes.txt"), b"x").unwrap();
|
||||
let files = list_direct_hook_json_files(dir).unwrap();
|
||||
assert_eq!(files.len(), 1);
|
||||
assert!(files[0].ends_with("active.json"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(unix)]
|
||||
fn validate_direct_hook_json_rejects_hardlink_and_symlink() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let f = tmp.path().join("a.json");
|
||||
let hl = tmp.path().join("b.json");
|
||||
std::fs::write(&f, b"{}").unwrap();
|
||||
std::fs::hard_link(&f, &hl).unwrap();
|
||||
assert!(matches!(
|
||||
validate_direct_hook_json_file(&f),
|
||||
Err(GlobalHookSourceError::HardLinkedHookFile { .. })
|
||||
));
|
||||
let real = tmp.path().join("real.json");
|
||||
let link = tmp.path().join("link.json");
|
||||
std::fs::write(&real, b"{}").unwrap();
|
||||
std::os::unix::fs::symlink(&real, &link).unwrap();
|
||||
assert!(matches!(
|
||||
validate_direct_hook_json_file(&link),
|
||||
Err(GlobalHookSourceError::SymlinkedSource { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ancestors_to_pin_skips_mountpoints_but_continues_above() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let outer = tmp.path().join("outer");
|
||||
let mid = outer.join("preexisting-bind");
|
||||
let leaf = mid.join("hooks");
|
||||
std::fs::create_dir_all(&leaf).unwrap();
|
||||
|
||||
// Synthetic: treat `preexisting-bind` as already a mountpoint.
|
||||
let pin = ancestors_to_pin_as_mountpoints_with(&leaf, |p| p == mid);
|
||||
assert!(
|
||||
pin.iter().any(|p| p == &outer),
|
||||
"must pin renameable ancestor ABOVE an intermediate mountpoint: {pin:?}"
|
||||
);
|
||||
assert!(
|
||||
!pin.iter().any(|p| p == &mid),
|
||||
"must NOT re-bind an already-mounted ancestor: {pin:?}"
|
||||
);
|
||||
assert!(
|
||||
!pin.iter().any(|p| p == Path::new("/")),
|
||||
"must never pin /: {pin:?}"
|
||||
);
|
||||
|
||||
// Immediate parent of leaf is mid (mountpoint) — skipped; outer still present.
|
||||
let sources = [GlobalHookSource {
|
||||
path: leaf,
|
||||
kind: GlobalHookSourceKind::ConfiguredSource,
|
||||
}];
|
||||
// With real mountpoint detector, under temp dirs nothing is a mount → full chain.
|
||||
let rootward = unique_ancestors_rootward(&sources);
|
||||
for w in rootward.windows(2) {
|
||||
assert!(
|
||||
w[0].components().count() <= w[1].components().count(),
|
||||
"rootward order broken: {rootward:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -16,6 +16,7 @@
|
|||
pub mod campaigns;
|
||||
pub mod config_override;
|
||||
pub mod fs_atomic;
|
||||
pub mod global_hook_sources;
|
||||
mod loader;
|
||||
mod macos_managed;
|
||||
mod managed_cache;
|
||||
|
|
@ -31,6 +32,17 @@ pub mod version_overrides;
|
|||
pub use campaigns::{
|
||||
CampaignEntry, CampaignOverrides, filter_active_campaigns, ids_touching_paths,
|
||||
};
|
||||
pub use global_hook_sources::{
|
||||
GlobalHookSource, GlobalHookSourceError, GlobalHookSourceKind, ResolvedGlobalHookSources,
|
||||
ensure_grok_hook_slots, existing_ancestor_chain, is_direct_hook_json_name,
|
||||
list_direct_hook_json_files, missing_configured_sources, path_has_symlink_component,
|
||||
resolve_global_hook_sources, unique_ancestors_rootward,
|
||||
};
|
||||
|
||||
#[cfg(unix)]
|
||||
pub use global_hook_sources::{
|
||||
validate_direct_hook_json_file, validated_hook_json_files_for_sources,
|
||||
};
|
||||
pub use loader::{
|
||||
CampaignsState, ConfigLayers, MANAGED_CONFIG_FILENAME, ManagedConfigLayer,
|
||||
REQUIREMENTS_FILENAME, apply_version_overrides_with_registered, campaigns_application_disabled,
|
||||
|
|
@ -43,7 +55,7 @@ pub use macos_managed::MDM_REQUIREMENTS_SOURCE;
|
|||
pub use managed_cache::{
|
||||
MANAGED_CONFIG_CACHE_FILE, ServingIdentity, SyncMarker, bump_rollback_floor,
|
||||
bump_rollback_floor_with_now, confirmed_team_switch, confirmed_team_switch_at,
|
||||
is_managed_config_hard_stale_for, is_managed_config_stale_for,
|
||||
fail_closed_policy_armed_at, is_managed_config_hard_stale_for, is_managed_config_stale_for,
|
||||
managed_config_identity_changed_at, managed_deployment_id, managed_policy_compromised_for,
|
||||
mark_managed_config_synced, mark_managed_config_synced_at, normalize_identity,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -175,6 +175,30 @@ fn write_marker_atomically(home: &Path, json: &str) {
|
|||
}
|
||||
}
|
||||
|
||||
/// Whether fail-closed managed policy is armed on disk for `home`.
|
||||
///
|
||||
/// True when the sync marker records `fail_closed`, on-disk `requirements.toml`
|
||||
/// parses as fail_closed, or `requirements.toml` exists but is unreadable
|
||||
/// (cannot confirm it is disarmed — must not let `clear_orphan` wipe).
|
||||
/// False only when neither the marker nor the file indicates fail_closed
|
||||
/// (including when the file is absent / `NotFound`).
|
||||
/// Companion to the signed session gate in [`managed_policy_compromised_for`].
|
||||
pub fn fail_closed_policy_armed_at(home: &Path) -> bool {
|
||||
if read_managed_config_cache(home).is_some_and(|c| c.fail_closed) {
|
||||
return true;
|
||||
}
|
||||
// Defense in depth: files remain after a stripped/corrupt marker.
|
||||
match std::fs::read_to_string(home.join(crate::loader::REQUIREMENTS_FILENAME)) {
|
||||
Ok(s) => prod_mc_cli_chat_proxy_types::fail_closed_flag_status(&s).is_enabled(),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => false,
|
||||
Err(e) => {
|
||||
// File present but unreadable: do not allow clear_orphan to wipe.
|
||||
tracing::warn!("requirements.toml unreadable; treating as fail_closed armed: {e}");
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The sync marker, or `None` if absent / unreadable / corrupt. Allow-on-unreadable:
|
||||
/// a read blip or torn write mustn't lock out a managed user. Unreadable/corrupt are
|
||||
/// logged (a corruption-to-disarm isn't silent) and self-heal on the next sync.
|
||||
|
|
|
|||
|
|
@ -1316,6 +1316,50 @@ fn managed_config_stale_for_far_future_sync() {
|
|||
);
|
||||
}
|
||||
|
||||
/// Unreadable requirements (PermissionDenied) with no fail_closed marker must
|
||||
/// still arm the gate so clear_orphan cannot wipe policy that may still be
|
||||
/// fail_closed on disk.
|
||||
#[test]
|
||||
#[cfg(unix)]
|
||||
fn unreadable_requirements_treats_fail_closed_as_armed() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let home = dir.path();
|
||||
let req = home.join(crate::loader::REQUIREMENTS_FILENAME);
|
||||
std::fs::write(&req, "fail_closed = true\n").unwrap();
|
||||
assert!(
|
||||
fail_closed_policy_armed_at(home),
|
||||
"readable fail_closed requirements must arm the gate"
|
||||
);
|
||||
|
||||
// Drop read perms so read_to_string fails with PermissionDenied (not NotFound).
|
||||
std::fs::set_permissions(&req, std::fs::Permissions::from_mode(0o000)).unwrap();
|
||||
// Restore on drop so tempfile cleanup can remove the file.
|
||||
struct RestorePerms<'a>(&'a std::path::Path);
|
||||
impl Drop for RestorePerms<'_> {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::set_permissions(self.0, std::fs::Permissions::from_mode(0o600));
|
||||
}
|
||||
}
|
||||
let _restore = RestorePerms(&req);
|
||||
|
||||
assert!(
|
||||
fail_closed_policy_armed_at(home),
|
||||
"unreadable requirements must treat fail_closed as armed (no wipe)"
|
||||
);
|
||||
}
|
||||
|
||||
/// Absent requirements + no fail_closed marker → not armed (safe to clear).
|
||||
#[test]
|
||||
fn missing_requirements_and_marker_not_armed() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
assert!(
|
||||
!fail_closed_policy_armed_at(dir.path()),
|
||||
"NotFound requirements with no marker must not arm fail_closed"
|
||||
);
|
||||
}
|
||||
|
||||
// The is-managed claim gate tests live in a sibling child module (this file is
|
||||
// past the 1k-line mark); same private access via the #[path] include below.
|
||||
#[path = "claim_tests.rs"]
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::Path;
|
||||
|
||||
use super::{ManagedConfigError, ManagedConfigRequest, ManagedItem};
|
||||
use super::{ManagedConfigError, ManagedConfigRequest, ManagedItem, ManagedItemState};
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct CommentSyntax {
|
||||
|
|
@ -94,6 +94,27 @@ pub(super) fn outer_block(
|
|||
.map(|(start, end)| text[start..end].trim_end_matches(['\r', '\n']).to_owned()))
|
||||
}
|
||||
|
||||
pub(super) fn item_state(
|
||||
original: &str,
|
||||
namespace: &str,
|
||||
owned_item_prefix: &str,
|
||||
item: &ManagedItem,
|
||||
comments: &CommentSyntax,
|
||||
path: &Path,
|
||||
) -> Result<ManagedItemState, ManagedConfigError> {
|
||||
let parsed = parse_block(original, namespace, owned_item_prefix, comments, path)?;
|
||||
let Some(range) = parsed.items.get(&item.name) else {
|
||||
return Ok(ManagedItemState::Absent);
|
||||
};
|
||||
let expected = item_section(item, comments, parsed.newline);
|
||||
let actual = original[range.start..range.end].trim_end_matches(['\r', '\n']);
|
||||
Ok(if actual == expected {
|
||||
ManagedItemState::Exact
|
||||
} else {
|
||||
ManagedItemState::NeedsUpdate
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn render_update(
|
||||
original: &str,
|
||||
namespace: &str,
|
||||
|
|
|
|||
|
|
@ -45,6 +45,14 @@ pub struct ManagedConfigRequest {
|
|||
pub struct ManagedTextInspection {
|
||||
original_text: Option<String>,
|
||||
unmanaged_text: String,
|
||||
requested_items: Vec<ManagedItemState>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum ManagedItemState {
|
||||
Absent,
|
||||
Exact,
|
||||
NeedsUpdate,
|
||||
}
|
||||
|
||||
impl ManagedTextInspection {
|
||||
|
|
@ -56,6 +64,10 @@ impl ManagedTextInspection {
|
|||
pub fn unmanaged_text(&self) -> &str {
|
||||
&self.unmanaged_text
|
||||
}
|
||||
|
||||
pub fn requested_item_state(&self, index: usize) -> Option<ManagedItemState> {
|
||||
self.requested_items.get(index).copied()
|
||||
}
|
||||
}
|
||||
|
||||
/// Immutable source and output state presented before application.
|
||||
|
|
@ -206,6 +218,20 @@ impl ManagedConfig {
|
|||
let parent_plan = ParentPlan::capture(parent)?;
|
||||
let original = source::read_source(&target_path)?;
|
||||
let text = original.text(&target_path)?;
|
||||
let requested_items = request
|
||||
.items
|
||||
.iter()
|
||||
.map(|item| {
|
||||
format::item_state(
|
||||
text,
|
||||
&request.namespace,
|
||||
&request.owned_item_prefix,
|
||||
item,
|
||||
&request.comments,
|
||||
&target_path,
|
||||
)
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
let rendered = format::render_update(
|
||||
text,
|
||||
&request.namespace,
|
||||
|
|
@ -217,6 +243,7 @@ impl ManagedConfig {
|
|||
let inspection = ManagedTextInspection {
|
||||
original_text: original.bytes.as_ref().map(|_| text.to_owned()),
|
||||
unmanaged_text: rendered.unmanaged_text,
|
||||
requested_items,
|
||||
};
|
||||
let updated = rendered.updated.into_bytes();
|
||||
let changes =
|
||||
|
|
@ -244,6 +271,13 @@ impl ManagedConfig {
|
|||
transaction::apply(plan, &transaction::NoopObserver)
|
||||
}
|
||||
|
||||
/// Verify that the exact source path, parent identities, symlink target,
|
||||
/// bytes, mode, and file identity captured by `plan` are unchanged without
|
||||
/// publishing its proposed update.
|
||||
pub fn verify_unchanged(plan: &ManagedConfigPlan) -> Result<(), ManagedConfigError> {
|
||||
source::revalidate(plan)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn apply_with_observer(
|
||||
plan: ManagedConfigPlan,
|
||||
|
|
|
|||
Loading…
Reference in a new issue