Preserve complete shell snapshot exports through filtering and replay (#43907)

## Why

Line-based export parsing can truncate multiline values or mistake their contents for declarations. Snapshot replay also needs to restore Bash options before parsing functions that use extended glob syntax.

## What changed

- Capture shell state, aliases, and complete export records with NUL-delimited boundaries, separating capture, credential preparation, and rendering.
- Apply credential and environment policy to whole exports, preserving multiline values and making credential aliases follow allowed overrides.
- Restore Bash `shopt` options before functions, including for Bash running as `sh`.
- Support non-evaluating POSIX `ENV` path expansion and preserve native environment metadata and unset exports.
- Move core and exec-server snapshot consumers to the shared capture parser. Allow capture overhead while enforcing the exec-server's 512 KiB state-and-environment limit before filtering.

## Testing

Add regression coverage for complete capture records, multiline export filtering and replay, credential alias overrides, `ENV` expansion, and `PATH` export state. Extend exec-server tests to cover Bash-backed `sh`, `extglob`, `nocasematch`, `set -u`, and large environments.

GitOrigin-RevId: 7589ab4d137529a395dcee10b5162718de24a621
This commit is contained in:
Winston Howes
2026-09-08 20:41:01 +00:00
committed by copyberry
parent f046cf35df
commit 1530f828cb
11 changed files with 1472 additions and 300 deletions
@@ -0,0 +1,69 @@
//! Serialize prepared snapshot values without inspecting credentials or executing shell code.
//! Native declarations stay unchanged unless the credential stage supplies a replacement.
use super::capture::CapturedSnapshot;
impl CapturedSnapshot<'_> {
/// Render native functions, options, and aliases; the executor restores environment separately.
pub fn render_state(&self) -> String {
format!("{}{}", self.state, self.aliases)
}
/// Render a complete replay script without applying credential or environment policy.
pub fn render_script(&self) -> String {
let mut script = self.render_state();
script.push_str("# exports (native declarations)\n");
for export in &self.exports {
script.push_str(&export.source);
}
script
}
}
#[derive(Default)]
pub(super) struct Value {
pub parts: Vec<ValuePart>,
}
pub(super) enum ValuePart {
Literal(String),
Credential { key: String },
}
pub(super) enum Export<'a> {
Captured(&'a str),
Assignment { declaration: &'a str, value: Value },
}
impl Value {
fn render(&self) -> Option<String> {
let mut output = String::new();
for part in &self.parts {
match part {
ValuePart::Literal(value) => {
output.push_str(shlex::try_quote(value).ok()?.as_ref())
}
ValuePart::Credential { key } => {
output.push_str(&format!("\"${{{key}-}}\""));
}
}
}
if output.is_empty() {
output.push_str("''");
}
Some(output)
}
}
pub(super) fn render(state: &str, aliases: &str, exports: &[Export<'_>]) -> Option<String> {
let mut output = format!("{state}{aliases}");
for export in exports {
match export {
Export::Captured(source) => output.push_str(source),
Export::Assignment { declaration, value } => {
output.push_str(&format!("{declaration}={}\n", value.render()?));
}
}
}
Some(output)
}