M0: compilable skeleton — Kigi 0.1.0 fork surgery
Hard fork of xai-org/grok-build (Apache-2.0) re-targeted as Kigi, an
unofficial Kimi Code CLI community build.
Rename & identity
- 72 xai-*/xai-grok-* crates -> kigi-* (explicit: xai-grok-pager-bin ->
kigi-bin [binary `kigi`], xai-grok-pager -> kigi-tui; rest mechanical);
ptyctl, ptyctl-cli, third_party/ unchanged; proto package
xai.grok.tools.v1 -> kigi.tools.v1
- Config home ~/.kigi (KIGI_SHARE_DIR override), env prefix GROK_* ->
KIGI_*, `kigi --version` carries the unofficial-community-build notice
- clap identity, help text, startup banner, prompt templates rebranded
(templates re-encrypted)
Deletions (PRD removal list #5/#6/#7/#9/#10)
- voice input (xai-grok-voice) and all TUI wiring
- telemetry: Mixpanel client, external OTel stream, Sentry, OTLP layers,
trace/GCS/S3 upload queues (kigi-file-utils halved), workspace upload
module & dc_log, heap-profile uploader, auth-diagnostics uploader,
session-analytics halves of feedback; local zero-egress observability
preserved in new kigi-log crate (unified log, --debug firehose,
subsystem file logs, opt-in instrumentation)
- announcements (crate, remote-settings fields, TUI surfaces)
- plugin marketplace (crate, sources/browse/CTA/extensions-modal tab);
direct plugin install/uninstall/update via kigi-agent git_install kept
- relay/gateway/assets endpoints and features (agent relay, headless
relay transport, gateway bridge, LeaderEnvUrls); leader IPC socket now
~/.kigi/leader.sock + KIGI_LEADER_SOCKET, no ws-url derivation
- functional types rehomed instead of deleted: PermissionMode ->
kigi-config-types, McpInitStrategy -> kigi-mcp, PrCreationSource ->
session signals, TerminalDiagnostics -> kigi-pager-render, agent_id ->
shell util
Endpoints
- kigi-env rewritten: single production KigiEndpoints {coding_api_base_url
https://api.kimi.com/coding/v1 (KIGI_CODE_BASE_URL), oauth_host
https://auth.kimi.com (KIGI_OAUTH_HOST), update_base_url (GitHub
Releases API), upgrade_page_url}; GrokBuildEnvironment enum deleted
Toolchain & workspace hygiene
- Rust 1.97.0 pinned; edition 2024; full cargo update; git2 hoisted to
workspace at 0.21 (Option->Result API migration), quick-xml 0.41
- Root Cargo.toml hand-maintained (PRD §8.1): version 0.1.0 inherited by
all members, members sorted, unused deps pruned
- cargo-deny advisories gate (deny.toml with documented transitive
exceptions); CI workflow (check/clippy/fmt/deny/test, macOS+Linux)
- cross-crate test seams re-gated behind `test-support` cargo feature;
insta snapshot baselines renamed to the kigi_tui prefix
- clippy --workspace --all-targets: zero warnings; fmt clean
Fixes surfaced by the port
- updater probe/installer divergence (bin/kigi vs bin/grok symlink set)
- idle model-metadata refresh dead under KIGI_CODE_BASE_URL override
(new is_effective_coding_endpoint_url, loopback+override aware)
- macOS symlinked-TMPDIR fixture canonicalization (foreign_sessions,
fast-worktree); RSS measurement tests serialized via serial_test
Docs & legal (Apache §4)
- NOTICE added (upstream attribution + change statement); THIRD-PARTY
notices sustained; kigi-tools ported-code notices extended; README,
CONTRIBUTING, SECURITY, AGENTS.md rewritten
Out of scope for M0 (tracked): Kimi auth/inference (M1), search/fetch,
command parity, config import (M2), Computer Hub excision & final
brand-token sweep (M2), distribution & self-update rewrite (M3).
This commit is contained in:
commit
d6c20fc13f
2612 files changed
+1353757
No files matched your search
@@ -0,0 +1,856 @@
|
||||
//! Environment variable expansion helper for hook config strings.
|
||||
//!
|
||||
//! Provides `${VAR}` / `$VAR` substitution that prefers a per-hook
|
||||
//! `extra_env` map over the process environment. Used by:
|
||||
//!
|
||||
//! * the JSON hook parser ([`crate::config::parse_hook_file`]) to expand
|
||||
//! `command` and `url` fields at config-load time, and
|
||||
//! * the HTTP runner ([`crate::runner::http`]) to expand `spec.url` once
|
||||
//! more right before SSRF validation, so plugin URLs that reference
|
||||
//! plugin-injected vars (e.g. `${CLAUDE_PLUGIN_ROOT}/check`) resolve.
|
||||
//!
|
||||
//! The expansion is **lossless on missing vars and on every parameter-
|
||||
//! expansion-modifier form** -- both unset plain references (e.g.
|
||||
//! `${UNSET}/x`) AND any modifier form (e.g. `${VAR:-default}`,
|
||||
//! `${VAR%pat}`, see the "Parameter-expansion forms" paragraph below)
|
||||
//! are preserved verbatim. This is important so that:
|
||||
//!
|
||||
//! * config-load-time expansion is idempotent (re-running it on an already
|
||||
//! expanded string is a no-op),
|
||||
//! * vars that are intentionally deferred to runtime (set later by the
|
||||
//! shell, the dispatcher, or `extra_env`) survive the load-time pass and
|
||||
//! are caught by the runtime pre-flight check in
|
||||
//! [`crate::runner::command`] if they remain unset at execution, and
|
||||
//! * shell-specific modifier semantics (especially `${VAR:-x}` for
|
||||
//! set-but-empty values) stay the responsibility of the runtime
|
||||
//! `sh -c` branch where they apply correctly.
|
||||
//!
|
||||
//! Parameter-expansion forms (`${VAR:-default}`, `${VAR-default}`,
|
||||
//! `${VAR:=x}`, `${VAR:?msg}`, `${VAR:+x}`, `${VAR%pat}`, `${VAR#pat}`,
|
||||
//! `${VAR/pat/repl}`, `${VAR:N}`, `${VAR:N:M}`) are also preserved
|
||||
//! verbatim. These forms have shell-specific semantics (notably the
|
||||
//! "set-but-empty" behaviour of `:-` differs between `sh` and the
|
||||
//! shellexpand crate) that the runtime `sh -c` branch resolves
|
||||
//! correctly. Mirroring the modifier-skip behaviour in
|
||||
//! [`crate::runner::command::find_unresolved_env_vars`] keeps the two
|
||||
//! layers in sync: the user wrote the modifier form because they wanted
|
||||
//! the shell's interpretation, so we leave it for the shell.
|
||||
//!
|
||||
//! Same underlying engine (`shellexpand::env_with_context_no_errors`)
|
||||
//! and same lossless-on-missing semantics as
|
||||
//! `kigi_config::expand_env_vars_in_string`, but with an additional
|
||||
//! per-hook `extra` map consulted before process env, and with the
|
||||
//! parameter-expansion-modifier preservation described above.
|
||||
//!
|
||||
//! ## Asymmetry between `command` and `url`
|
||||
//!
|
||||
//! Load-time expansion in [`crate::config::parse_hook_file`] runs once
|
||||
//! using a snapshot of process env at parse time. The HTTP runner does
|
||||
//! a second pass at runtime so plugin-injected vars that arrive in
|
||||
//! `extra_env` after parsing (e.g. `CLAUDE_PLUGIN_ROOT`) resolve, and
|
||||
//! so mid-session changes to process env are picked up for URLs.
|
||||
//! Command paths are NOT re-expanded at runtime; the runtime `sh -c`
|
||||
//! branch in [`crate::runner::command`] picks up mid-session env
|
||||
//! changes for commands that contain shell metacharacters, but
|
||||
//! direct-exec paths see only the parse-time snapshot. Document this
|
||||
//! contract on `HookSpec::command` / `HookSpec::url`.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Sentinel prefix for the per-call mask sentinel; see [`make_sentinel`].
|
||||
///
|
||||
/// Uses a Unicode Private Use Area code point (`U+F8FF`, the
|
||||
/// "Apple logo" PUA char) plus a long magic ASCII prefix. The full
|
||||
/// sentinel string adds 128 bits of per-call entropy as a hex suffix
|
||||
/// followed by another `U+F8FF` char.
|
||||
const SENTINEL_PREFIX: &str = "\u{f8ff}__GROK_HOOKS_MASK_";
|
||||
const SENTINEL_SUFFIX: &str = "__\u{f8ff}";
|
||||
|
||||
/// Build a per-call sentinel string used to hide modifier-form
|
||||
/// `${...}` substrings from `shellexpand::env_with_context_no_errors`.
|
||||
/// The sentinel is restored to `${` after shellexpand runs, so the
|
||||
/// modifier form survives expansion verbatim.
|
||||
///
|
||||
/// The sentinel is randomized on every call: 128 bits of entropy
|
||||
/// from `fastrand` are formatted as hex between the fixed
|
||||
/// [`SENTINEL_PREFIX`] / [`SENTINEL_SUFFIX`] markers. The chance of
|
||||
/// a natural collision with arbitrary user-supplied input or a
|
||||
/// modifier body is ~2^-128, removing the sentinel-substring
|
||||
/// rewrite hazard that a fixed-string sentinel had.
|
||||
///
|
||||
/// Properties:
|
||||
///
|
||||
/// * **Unambiguous** -- per-call randomization makes accidental
|
||||
/// collision with any real hook command/URL string or value
|
||||
/// extracted from `extra_env` vanishingly unlikely.
|
||||
/// * **UTF-8 safe** -- the leading and trailing PUA chars are 3-byte
|
||||
/// UTF-8 sequences; the middle is ASCII hex.
|
||||
/// * **Visually distinct in panic messages / logs** if a sentinel
|
||||
/// ever escapes back to the user (it shouldn't, but if it does
|
||||
/// the magic string makes triage immediate).
|
||||
///
|
||||
/// Replaces a previous fixed sentinel (and an even earlier 2-NUL-byte
|
||||
/// sentinel `"\u{0}\u{0}"`) which could collide with a hand-crafted
|
||||
/// `extra_env` value or modifier body containing the same byte
|
||||
/// sequence; see the
|
||||
/// `mask_helper_preserves_pre_existing_old_nul_sentinel`,
|
||||
/// `expand_preserves_pre_existing_legacy_fixed_sentinel_in_extra`,
|
||||
/// and related regression tests which construct legacy collision
|
||||
/// inputs and assert they are preserved verbatim.
|
||||
fn make_sentinel() -> String {
|
||||
let hi: u64 = fastrand::u64(..);
|
||||
let lo: u64 = fastrand::u64(..);
|
||||
format!("{SENTINEL_PREFIX}{hi:016x}{lo:016x}{SENTINEL_SUFFIX}")
|
||||
}
|
||||
|
||||
/// Expand `${VAR}` / `$VAR` references in `input`.
|
||||
///
|
||||
/// Lookup order for each reference:
|
||||
/// 1. `extra` (the per-hook `extra_env` map)
|
||||
/// 2. The current process environment
|
||||
///
|
||||
/// Unresolved references are preserved verbatim so this function is safe
|
||||
/// to call repeatedly (idempotent on already-expanded strings) and so
|
||||
/// references that are intentionally resolved at runtime (e.g. by the
|
||||
/// dispatcher's always-set `KIGI_HOOK_*` vars) survive the load-time pass.
|
||||
///
|
||||
/// Parameter-expansion-modifier forms (`${VAR:-x}`, `${VAR%pat}`, etc.)
|
||||
/// are ALSO preserved verbatim; see the module-level rustdoc for why.
|
||||
pub(crate) fn expand_env_vars_with_extra(input: &str, extra: &HashMap<String, String>) -> String {
|
||||
// Generate a fresh per-call sentinel. 128 bits of entropy means a
|
||||
// natural collision with any input substring or extra-env value is
|
||||
// ~2^-128 probability. See `make_sentinel` rustdoc.
|
||||
let sentinel = make_sentinel();
|
||||
|
||||
// Defence in depth: if the freshly-generated sentinel ever happens
|
||||
// to appear in the input or in any extra-env value (vanishingly
|
||||
// unlikely; would require an adversary to predict our PRNG output),
|
||||
// panic in debug builds and fall through to legacy behaviour in
|
||||
// release. Returning the input unchanged is safer than rewriting a
|
||||
// legitimate substring to `${`.
|
||||
debug_assert!(
|
||||
!input.contains(&sentinel) && !extra.values().any(|v| v.contains(&sentinel)),
|
||||
"per-call sentinel collided with input or extra-env value"
|
||||
);
|
||||
|
||||
// Step 1: hide any `${VAR<modifier>...}` substring from shellexpand by
|
||||
// replacing the leading `${` with the per-call sentinel. shellexpand's
|
||||
// grammar requires `$` before a brace to recognize the form, so
|
||||
// replacing the leading `${` with a non-`$` sentinel makes the body
|
||||
// look like literal text to the expander.
|
||||
let masked = mask_modifier_forms(input, &sentinel);
|
||||
|
||||
// Step 2: run shellexpand on the (possibly) masked input.
|
||||
let context = |name: &str| -> Option<String> {
|
||||
if let Some(v) = extra.get(name) {
|
||||
return Some(v.clone());
|
||||
}
|
||||
std::env::var(name).ok()
|
||||
};
|
||||
let expanded = shellexpand::env_with_context_no_errors(&masked, context).into_owned();
|
||||
|
||||
// Step 3: restore the sentinels back to `${`. Because the sentinel is
|
||||
// freshly randomized per call, the only way it appears in `expanded`
|
||||
// is if `mask_modifier_forms` put it there.
|
||||
if expanded.contains(&sentinel) {
|
||||
expanded.replace(&sentinel, "${")
|
||||
} else {
|
||||
expanded
|
||||
}
|
||||
}
|
||||
|
||||
/// Walk `input` and, for every `${...}` substring whose contents are a
|
||||
/// valid identifier followed by a parameter-expansion modifier, replace
|
||||
/// the leading `${` with `sentinel`. Plain `${VAR}` and bare `$VAR`
|
||||
/// references are NOT touched -- they are passed through to shellexpand
|
||||
/// for normal resolution.
|
||||
///
|
||||
/// "Modifier" here means anything inside the braces after the
|
||||
/// identifier name: `:-`, `-`, `:=`, `=`, `:?`, `?`, `:+`, `+`, `%`,
|
||||
/// `#`, `/`, `:N` (digit), `:N:M`, etc. This shares its detection
|
||||
/// logic with [`crate::runner::command::find_unresolved_env_vars`] via
|
||||
/// [`iter_env_var_references`].
|
||||
fn mask_modifier_forms(input: &str, sentinel: &str) -> String {
|
||||
let mut out = String::with_capacity(input.len());
|
||||
let mut cursor: usize = 0;
|
||||
for r in iter_env_var_references(input) {
|
||||
// Copy any literal text between the previous reference (or
|
||||
// start of string) and this one verbatim.
|
||||
if cursor < r.start {
|
||||
out.push_str(&input[cursor..r.start]);
|
||||
}
|
||||
// Modifier-form braced ref: replace leading `${` with sentinel
|
||||
// and emit the body (including closing `}`) as-is.
|
||||
if r.braced && r.has_modifier {
|
||||
out.push_str(sentinel);
|
||||
// body_start = r.start + 2 (past `${`); copy up to and
|
||||
// including the closing `}` at r.end - 1.
|
||||
out.push_str(&input[r.start + 2..r.end]);
|
||||
} else {
|
||||
// Plain `${NAME}`, bare `$NAME`, or invalid form: pass
|
||||
// through verbatim so shellexpand can resolve (or leave
|
||||
// unresolved).
|
||||
out.push_str(&input[r.start..r.end]);
|
||||
}
|
||||
cursor = r.end;
|
||||
}
|
||||
// Copy the trailing literal tail.
|
||||
if cursor < input.len() {
|
||||
out.push_str(&input[cursor..]);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// One detected env-var reference in a string, as produced by
|
||||
/// [`iter_env_var_references`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct EnvVarRef<'a> {
|
||||
/// Byte offset where the leading `$` starts.
|
||||
pub start: usize,
|
||||
/// Byte offset one past the end of the reference. For braced
|
||||
/// forms this is one past the closing `}`; for bare forms it is
|
||||
/// one past the last identifier character.
|
||||
pub end: usize,
|
||||
/// Identifier name. For `${VAR...}` and `$VAR` this is `"VAR"`;
|
||||
/// for invalid forms (e.g. `${:-foo}`, `${}`) it is empty.
|
||||
pub name: &'a str,
|
||||
/// True for `${...}` (braced); false for `$NAME` (bare).
|
||||
pub braced: bool,
|
||||
/// True if the braced form contains a parameter-expansion
|
||||
/// modifier between the identifier and the closing `}`
|
||||
/// (`:`, `-`, `=`, `?`, `+`, `%`, `#`, `/`, digit suffix, etc.).
|
||||
/// Always false for bare references and for invalid braced forms.
|
||||
pub has_modifier: bool,
|
||||
}
|
||||
|
||||
/// Walk `input` and yield every `$VAR` / `${...}` reference. Skips
|
||||
/// shell positional / special params (`$1`, `$$`, `$?`, `$#`,
|
||||
/// `$(...)`, `$@`, etc.) since none of those are env-var references.
|
||||
///
|
||||
/// Behaviour notes:
|
||||
///
|
||||
/// * Unterminated braced forms (`${VAR:-no-close`) are skipped: the
|
||||
/// `$` is consumed and scanning continues at the next byte. This
|
||||
/// matches `shellexpand`'s behaviour of treating unterminated
|
||||
/// forms as literal text.
|
||||
/// * Nested braces inside a modifier body (`${A:-${B}}`) are handled
|
||||
/// by matching the FIRST `}` -- the inner `${B}` becomes part of
|
||||
/// the outer modifier body. This mirrors the legacy parser
|
||||
/// behaviour (and the runtime `sh -c` branch handles real nesting
|
||||
/// natively when the form reaches the shell).
|
||||
/// * Empty / invalid identifier (`${}`, `${:-foo}`) is yielded with
|
||||
/// an empty `name`, so callers can decide whether to mask it.
|
||||
pub(crate) fn iter_env_var_references(input: &str) -> EnvVarRefIter<'_> {
|
||||
EnvVarRefIter { input, pos: 0 }
|
||||
}
|
||||
|
||||
pub(crate) struct EnvVarRefIter<'a> {
|
||||
input: &'a str,
|
||||
pos: usize,
|
||||
}
|
||||
|
||||
impl<'a> Iterator for EnvVarRefIter<'a> {
|
||||
type Item = EnvVarRef<'a>;
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
let bytes = self.input.as_bytes();
|
||||
while self.pos < bytes.len() {
|
||||
if bytes[self.pos] != b'$' {
|
||||
self.pos += 1;
|
||||
continue;
|
||||
}
|
||||
let dollar = self.pos;
|
||||
// Past-the-`$` index.
|
||||
let after = dollar + 1;
|
||||
if after >= bytes.len() {
|
||||
// Trailing lone `$` -- not a reference. Stop.
|
||||
self.pos = bytes.len();
|
||||
return None;
|
||||
}
|
||||
if bytes[after] == b'{' {
|
||||
// Braced form: ${...}
|
||||
let body_start = after + 1;
|
||||
// Read identifier prefix (alphanumeric / underscore).
|
||||
let mut name_end = body_start;
|
||||
while name_end < bytes.len()
|
||||
&& (bytes[name_end].is_ascii_alphanumeric() || bytes[name_end] == b'_')
|
||||
{
|
||||
name_end += 1;
|
||||
}
|
||||
// Find the FIRST closing `}` from the identifier end.
|
||||
let mut close = name_end;
|
||||
while close < bytes.len() && bytes[close] != b'}' {
|
||||
close += 1;
|
||||
}
|
||||
if close >= bytes.len() {
|
||||
// Unterminated brace -- not a real form. Skip the
|
||||
// `$` and keep scanning.
|
||||
self.pos = dollar + 1;
|
||||
continue;
|
||||
}
|
||||
let name = std::str::from_utf8(&bytes[body_start..name_end]).unwrap_or("");
|
||||
let has_modifier = !name.is_empty() && name_end < close;
|
||||
let end = close + 1;
|
||||
self.pos = end;
|
||||
return Some(EnvVarRef {
|
||||
start: dollar,
|
||||
end,
|
||||
name,
|
||||
braced: true,
|
||||
has_modifier,
|
||||
});
|
||||
}
|
||||
// Bare `$NAME`: identifier must start with letter / `_`.
|
||||
// Anything else (`$1`, `$$`, `$?`, `$#`, `$(`, etc.) is a
|
||||
// shell special and not an env-var reference.
|
||||
if bytes[after].is_ascii_alphabetic() || bytes[after] == b'_' {
|
||||
let start_id = after;
|
||||
let mut end_id = start_id;
|
||||
while end_id < bytes.len()
|
||||
&& (bytes[end_id].is_ascii_alphanumeric() || bytes[end_id] == b'_')
|
||||
{
|
||||
end_id += 1;
|
||||
}
|
||||
let name = std::str::from_utf8(&bytes[start_id..end_id]).unwrap_or("");
|
||||
self.pos = end_id;
|
||||
return Some(EnvVarRef {
|
||||
start: dollar,
|
||||
end: end_id,
|
||||
name,
|
||||
braced: false,
|
||||
has_modifier: false,
|
||||
});
|
||||
}
|
||||
// `$` followed by a non-identifier, non-`{` byte. Skip
|
||||
// both bytes and continue.
|
||||
self.pos = after + 1;
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::test_support::with_env_var;
|
||||
|
||||
#[test]
|
||||
fn expands_braced_var_from_extra() {
|
||||
let mut extra = HashMap::new();
|
||||
extra.insert("PLUGIN_HOST".to_string(), "example.com".to_string());
|
||||
let out = expand_env_vars_with_extra("https://${PLUGIN_HOST}/check", &extra);
|
||||
assert_eq!(out, "https://example.com/check");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expands_bare_var_from_extra() {
|
||||
let mut extra = HashMap::new();
|
||||
extra.insert("ROOT".to_string(), "/opt/plugin".to_string());
|
||||
let out = expand_env_vars_with_extra("$ROOT/bin/x.sh", &extra);
|
||||
assert_eq!(out, "/opt/plugin/bin/x.sh");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extra_takes_precedence_over_process_env() {
|
||||
with_env_var(
|
||||
"KIGI_HOOKS_ENV_EXPAND_TEST_PRECEDENCE",
|
||||
Some("from-process"),
|
||||
|| {
|
||||
let mut extra = HashMap::new();
|
||||
extra.insert(
|
||||
"KIGI_HOOKS_ENV_EXPAND_TEST_PRECEDENCE".to_string(),
|
||||
"from-extra".to_string(),
|
||||
);
|
||||
let out =
|
||||
expand_env_vars_with_extra("${KIGI_HOOKS_ENV_EXPAND_TEST_PRECEDENCE}", &extra);
|
||||
assert_eq!(out, "from-extra");
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn falls_back_to_process_env() {
|
||||
with_env_var(
|
||||
"KIGI_HOOKS_ENV_EXPAND_TEST_FALLBACK",
|
||||
Some("/from/proc/env"),
|
||||
|| {
|
||||
let extra = HashMap::new();
|
||||
let out =
|
||||
expand_env_vars_with_extra("${KIGI_HOOKS_ENV_EXPAND_TEST_FALLBACK}/x", &extra);
|
||||
assert_eq!(out, "/from/proc/env/x");
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_unresolved_references() {
|
||||
// shellexpand's no-errors variant returns the original `${VAR}` text
|
||||
// when the var is unset in both `extra` and the process env. This
|
||||
// makes load-time expansion idempotent and lets runtime-only vars
|
||||
// survive the pass to be caught by `find_unresolved_env_vars`.
|
||||
with_env_var("KIGI_HOOKS_ENV_EXPAND_NEVER_SET", None, || {
|
||||
let extra = HashMap::new();
|
||||
let input = "${KIGI_HOOKS_ENV_EXPAND_NEVER_SET}/x.sh";
|
||||
let out = expand_env_vars_with_extra(input, &extra);
|
||||
assert_eq!(out, input);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idempotent_on_already_expanded_string() {
|
||||
let extra = HashMap::new();
|
||||
let already = "/opt/plugins/foo/hooks/x.sh";
|
||||
let out = expand_env_vars_with_extra(already, &extra);
|
||||
assert_eq!(out, already);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_input_returns_empty() {
|
||||
let extra = HashMap::new();
|
||||
assert_eq!(expand_env_vars_with_extra("", &extra), "");
|
||||
}
|
||||
|
||||
// ── Parameter-expansion-modifier preservation ───────────────
|
||||
|
||||
/// `${VAR:-default}` must be preserved verbatim, even when `VAR` is
|
||||
/// unset at expand time. Otherwise shellexpand resolves to the
|
||||
/// literal default and the runtime branch never gets a chance to
|
||||
/// see `VAR`'s real (runtime-only) value.
|
||||
#[test]
|
||||
fn preserves_default_modifier_when_var_unset() {
|
||||
let extra = HashMap::new();
|
||||
with_env_var("KIGI_HOOKS_ENV_EXPAND_MODIFIER_UNSET", None, || {
|
||||
let input = "${KIGI_HOOKS_ENV_EXPAND_MODIFIER_UNSET:-/default/path.sh}";
|
||||
let out = expand_env_vars_with_extra(input, &extra);
|
||||
assert_eq!(out, input);
|
||||
});
|
||||
}
|
||||
|
||||
/// Even when the var IS set, the modifier form must be preserved
|
||||
/// verbatim -- the shell's `:-` semantics differ from shellexpand's
|
||||
/// (notably for set-but-empty values), so deferring the entire form
|
||||
/// to the runtime `sh -c` branch is the only safe choice.
|
||||
#[test]
|
||||
fn preserves_default_modifier_when_var_set() {
|
||||
let mut extra = HashMap::new();
|
||||
extra.insert(
|
||||
"KIGI_HOOKS_DEFAULT_SET".to_string(),
|
||||
"/from/extra".to_string(),
|
||||
);
|
||||
let input = "${KIGI_HOOKS_DEFAULT_SET:-/fallback}";
|
||||
let out = expand_env_vars_with_extra(input, &extra);
|
||||
assert_eq!(out, input);
|
||||
}
|
||||
|
||||
/// `${VAR-default}` (no colon) — also a modifier form.
|
||||
#[test]
|
||||
fn preserves_no_colon_default_modifier() {
|
||||
let extra = HashMap::new();
|
||||
let input = "${KIGI_HOOKS_NCD-/fallback}";
|
||||
let out = expand_env_vars_with_extra(input, &extra);
|
||||
assert_eq!(out, input);
|
||||
}
|
||||
|
||||
/// `${VAR:=x}` — assignment modifier.
|
||||
#[test]
|
||||
fn preserves_assignment_modifier() {
|
||||
let extra = HashMap::new();
|
||||
let input = "${KIGI_HOOKS_ASSIGN:=/assigned/path.sh}";
|
||||
let out = expand_env_vars_with_extra(input, &extra);
|
||||
assert_eq!(out, input);
|
||||
}
|
||||
|
||||
/// `${VAR:?msg}` — error modifier.
|
||||
#[test]
|
||||
fn preserves_error_modifier() {
|
||||
let extra = HashMap::new();
|
||||
let input = "${KIGI_HOOKS_ERR:?error message}";
|
||||
let out = expand_env_vars_with_extra(input, &extra);
|
||||
assert_eq!(out, input);
|
||||
}
|
||||
|
||||
/// `${VAR:+x}` — alternate-value modifier.
|
||||
#[test]
|
||||
fn preserves_alternate_modifier() {
|
||||
let extra = HashMap::new();
|
||||
let input = "${KIGI_HOOKS_ALT:+/used/if/set}";
|
||||
let out = expand_env_vars_with_extra(input, &extra);
|
||||
assert_eq!(out, input);
|
||||
}
|
||||
|
||||
/// `${VAR%pat}` — suffix-strip modifier.
|
||||
#[test]
|
||||
fn preserves_suffix_strip_modifier() {
|
||||
let extra = HashMap::new();
|
||||
let input = "${KIGI_HOOKS_SUFFIX%.sh}";
|
||||
let out = expand_env_vars_with_extra(input, &extra);
|
||||
assert_eq!(out, input);
|
||||
}
|
||||
|
||||
/// `${VAR#pat}` — prefix-strip modifier.
|
||||
#[test]
|
||||
fn preserves_prefix_strip_modifier() {
|
||||
let extra = HashMap::new();
|
||||
let input = "${KIGI_HOOKS_PREFIX#prefix/}";
|
||||
let out = expand_env_vars_with_extra(input, &extra);
|
||||
assert_eq!(out, input);
|
||||
}
|
||||
|
||||
/// `${VAR/foo/bar}` — pattern-substitution modifier.
|
||||
#[test]
|
||||
fn preserves_substitution_modifier() {
|
||||
let extra = HashMap::new();
|
||||
let input = "${KIGI_HOOKS_SUB/foo/bar}";
|
||||
let out = expand_env_vars_with_extra(input, &extra);
|
||||
assert_eq!(out, input);
|
||||
}
|
||||
|
||||
/// `${VAR:N:M}` — substring modifier.
|
||||
#[test]
|
||||
fn preserves_substring_modifier() {
|
||||
let extra = HashMap::new();
|
||||
let input = "${KIGI_HOOKS_SUBSTR:0:5}";
|
||||
let out = expand_env_vars_with_extra(input, &extra);
|
||||
assert_eq!(out, input);
|
||||
}
|
||||
|
||||
/// Mixed: a modifier-form sits next to a plain form; only the plain
|
||||
/// one is expanded.
|
||||
#[test]
|
||||
fn mixed_plain_and_modifier_only_plain_expanded() {
|
||||
let mut extra = HashMap::new();
|
||||
extra.insert("KIGI_HOOKS_PLAIN".to_string(), "/usr/local".to_string());
|
||||
let input = "${KIGI_HOOKS_PLAIN}/${KIGI_HOOKS_DEFER:-/fallback}";
|
||||
let out = expand_env_vars_with_extra(input, &extra);
|
||||
assert_eq!(out, "/usr/local/${KIGI_HOOKS_DEFER:-/fallback}");
|
||||
}
|
||||
|
||||
// ── Set-but-empty regression test ────────────────────────────
|
||||
|
||||
/// When the var is set in `extra` but to the empty string, the
|
||||
/// no-modifier form `${VAR}` resolves to "" (matching shellexpand's
|
||||
/// behaviour and what users typically expect).
|
||||
#[test]
|
||||
fn empty_extra_value_resolves_to_empty_for_plain_form() {
|
||||
let mut extra = HashMap::new();
|
||||
extra.insert("KIGI_HOOKS_EMPTY".to_string(), "".to_string());
|
||||
let out = expand_env_vars_with_extra("[${KIGI_HOOKS_EMPTY}]", &extra);
|
||||
assert_eq!(out, "[]");
|
||||
}
|
||||
|
||||
/// When the var is set in `extra` but to the empty string, the
|
||||
/// modifier-form `${VAR:-default}` is preserved verbatim (so that
|
||||
/// the runtime `sh -c` branch can apply POSIX `:-` semantics, which
|
||||
/// differ from shellexpand's: bash returns the default for empty
|
||||
/// values, shellexpand returns the empty string). This documents
|
||||
/// that the load-time pass does NOT trigger the modifier branch.
|
||||
#[test]
|
||||
fn empty_extra_value_does_not_trigger_default() {
|
||||
let mut extra = HashMap::new();
|
||||
extra.insert("KIGI_HOOKS_EMPTY_MOD".to_string(), "".to_string());
|
||||
let input = "${KIGI_HOOKS_EMPTY_MOD:-/fallback}";
|
||||
let out = expand_env_vars_with_extra(input, &extra);
|
||||
assert_eq!(out, input);
|
||||
}
|
||||
|
||||
// ── Single-pass expansion (no recursion) ────────────────────
|
||||
|
||||
/// A value in `extra` that itself contains a `$VAR` reference must
|
||||
/// NOT be re-expanded. Recursion would be a DoS vector and a
|
||||
/// semantic surprise. shellexpand's
|
||||
/// `env_with_context_no_errors` is single-pass by design; this
|
||||
/// test locks the property in.
|
||||
#[test]
|
||||
fn extra_values_are_not_recursively_expanded() {
|
||||
with_env_var(
|
||||
"KIGI_HOOKS_RECURSION_BAR",
|
||||
Some("should-not-appear"),
|
||||
|| {
|
||||
let mut extra = HashMap::new();
|
||||
extra.insert(
|
||||
"KIGI_HOOKS_RECURSION_FOO".to_string(),
|
||||
"$KIGI_HOOKS_RECURSION_BAR".to_string(),
|
||||
);
|
||||
let out = expand_env_vars_with_extra("${KIGI_HOOKS_RECURSION_FOO}", &extra);
|
||||
assert_eq!(out, "$KIGI_HOOKS_RECURSION_BAR");
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ── mask_modifier_forms helper unit tests ────────────────────
|
||||
|
||||
/// A fixed test-only sentinel used to make the masked-output
|
||||
/// assertions deterministic. Production code uses [`make_sentinel`]
|
||||
/// which returns a per-call randomized value (see the sentinel
|
||||
/// collision regression test below that exercises the random
|
||||
/// path end-to-end).
|
||||
const TEST_SENTINEL: &str = "<<TEST_SENTINEL>>";
|
||||
|
||||
#[test]
|
||||
fn mask_helper_passes_plain_form_through() {
|
||||
assert_eq!(mask_modifier_forms("${PLAIN}", TEST_SENTINEL), "${PLAIN}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mask_helper_masks_default_form() {
|
||||
// Lock down the exact masked output, not
|
||||
// just the sentinel-contains predicate.
|
||||
let masked = mask_modifier_forms("${VAR:-x}", TEST_SENTINEL);
|
||||
assert_eq!(masked, format!("{TEST_SENTINEL}VAR:-x}}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mask_helper_handles_unterminated_brace() {
|
||||
// No closing brace -- no masking, emit verbatim.
|
||||
assert_eq!(
|
||||
mask_modifier_forms("${VAR:-no-close", TEST_SENTINEL),
|
||||
"${VAR:-no-close"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mask_helper_passes_bare_form_through() {
|
||||
assert_eq!(mask_modifier_forms("$BARE_VAR", TEST_SENTINEL), "$BARE_VAR");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mask_helper_handles_multibyte_chars() {
|
||||
// Full-equality assertion locks down the
|
||||
// exact bytes, including UTF-8 boundary placement.
|
||||
let input = "h\u{e9}llo${PLAIN}w\u{f6}rld${VAR:-x}";
|
||||
let masked = mask_modifier_forms(input, TEST_SENTINEL);
|
||||
let expected = format!("h\u{e9}llo${{PLAIN}}w\u{f6}rld{TEST_SENTINEL}VAR:-x}}");
|
||||
assert_eq!(masked, expected);
|
||||
}
|
||||
|
||||
// ── Nested / interleaved edge cases ─────────────────────────
|
||||
|
||||
/// Two consecutive modifier forms with no
|
||||
/// intervening text. Both must be masked independently.
|
||||
#[test]
|
||||
fn mask_helper_consecutive_modifier_forms() {
|
||||
let masked = mask_modifier_forms("${A:-x}${B:-y}", TEST_SENTINEL);
|
||||
assert_eq!(
|
||||
masked,
|
||||
format!("{TEST_SENTINEL}A:-x}}{TEST_SENTINEL}B:-y}}")
|
||||
);
|
||||
}
|
||||
|
||||
/// Nested braces inside a modifier body. The
|
||||
/// custom byte-walker matches the FIRST closing `}`, so the
|
||||
/// inner `${B}` is NOT a separately-recognised plain form -- it
|
||||
/// becomes part of the outer modifier body and is masked along
|
||||
/// with the outer form. The literal `${B}` is preserved inside
|
||||
/// the masked body, ready for the runtime `sh -c` branch (which
|
||||
/// handles nesting natively).
|
||||
///
|
||||
/// The trailing extra `}` is left as-is (it has no matching `${`).
|
||||
/// This documented behaviour is intentional: complex nested
|
||||
/// expansions are an explicit deferral to runtime.
|
||||
#[test]
|
||||
fn mask_helper_nested_braces_in_modifier_body() {
|
||||
let masked = mask_modifier_forms("${A:-${B}}", TEST_SENTINEL);
|
||||
// First `}` closes the outer modifier match; `${B}` is INSIDE
|
||||
// the masked body. The tail `}` is a stray brace, preserved
|
||||
// as-is.
|
||||
assert_eq!(masked, format!("{TEST_SENTINEL}A:-${{B}}}}"));
|
||||
}
|
||||
|
||||
/// A closed plain form followed by an
|
||||
/// unterminated modifier form. The plain form passes through;
|
||||
/// the unterminated tail is emitted verbatim because the walker
|
||||
/// requires a closing `}` to consider a `${...}` substring a
|
||||
/// real form.
|
||||
#[test]
|
||||
fn mask_helper_closed_then_unterminated() {
|
||||
let masked = mask_modifier_forms("${A}${B:-", TEST_SENTINEL);
|
||||
assert_eq!(masked, "${A}${B:-");
|
||||
}
|
||||
|
||||
// ── Sentinel collision regression ──────────────────────────
|
||||
|
||||
/// The previous sentinel was `\x00\x00`. If a
|
||||
/// future change reverted to that sentinel, an `extra_env` value
|
||||
/// or input string containing the same byte sequence would be
|
||||
/// silently rewritten to `${`. The new sentinel is a long magic
|
||||
/// ASCII string sandwiched between two PUA characters --
|
||||
/// vanishingly unlikely to collide. This regression test
|
||||
/// constructs an input containing the OLD `\x00\x00` sequence
|
||||
/// AND a value containing the OLD sequence in `extra_env`, and
|
||||
/// asserts both pass through unchanged.
|
||||
#[test]
|
||||
fn mask_helper_preserves_pre_existing_old_nul_sentinel() {
|
||||
// The OLD sentinel as a literal in the input.
|
||||
let input = "prefix\u{0}\u{0}suffix";
|
||||
assert_eq!(mask_modifier_forms(input, TEST_SENTINEL), input);
|
||||
}
|
||||
|
||||
/// Companion to the above: an `extra_env` value containing the
|
||||
/// OLD sentinel must not be rewritten to `${...}` after expansion.
|
||||
#[test]
|
||||
fn expand_preserves_pre_existing_old_nul_sentinel_in_extra() {
|
||||
let mut extra = HashMap::new();
|
||||
// Value contains the legacy 2-NUL sentinel followed by what
|
||||
// would have been parsed as an identifier+brace.
|
||||
extra.insert("VAL".to_string(), "\u{0}\u{0}OLD}".to_string());
|
||||
let out = expand_env_vars_with_extra("prefix${VAL}suffix", &extra);
|
||||
// Output must contain the literal NUL bytes verbatim, NOT
|
||||
// `${OLD}`.
|
||||
assert_eq!(out, "prefix\u{0}\u{0}OLD}suffix");
|
||||
assert!(
|
||||
!out.contains("${OLD}"),
|
||||
"legacy sentinel must NOT trigger an unmask-to-`${{`, got {out:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// An earlier sentinel was a fixed string
|
||||
/// `"\u{f8ff}__GROK_HOOKS_MASK__\u{f8ff}"`. A user-supplied
|
||||
/// `extra_env` value containing that exact byte sequence would
|
||||
/// have been silently rewritten to `${` by the unmask step. The
|
||||
/// per-call randomized sentinel removes this hazard. This
|
||||
/// regression test asserts the legacy fixed sentinel passes
|
||||
/// through verbatim when it appears in an extra-env value, even
|
||||
/// though the input also references that variable through `${VAL}`.
|
||||
#[test]
|
||||
fn expand_preserves_pre_existing_legacy_fixed_sentinel_in_extra() {
|
||||
let legacy_sentinel = "\u{f8ff}__GROK_HOOKS_MASK__\u{f8ff}";
|
||||
let mut extra = HashMap::new();
|
||||
// Value embeds the legacy sentinel followed by what would
|
||||
// have been parsed as an identifier+brace if the unmask
|
||||
// sentinel-replace had collided.
|
||||
extra.insert(
|
||||
"VAL".to_string(),
|
||||
format!("payload-{legacy_sentinel}OLD}}-tail"),
|
||||
);
|
||||
// Reference VAL via a plain form so its value gets spliced
|
||||
// into the output.
|
||||
let out = expand_env_vars_with_extra("prefix${VAL}suffix", &extra);
|
||||
// The legacy sentinel substring must appear in the output
|
||||
// verbatim -- it must NOT be rewritten to `${`.
|
||||
assert_eq!(
|
||||
out,
|
||||
format!("prefixpayload-{legacy_sentinel}OLD}}-tailsuffix")
|
||||
);
|
||||
assert!(
|
||||
!out.contains("${OLD}"),
|
||||
"legacy fixed sentinel must NOT trigger an unmask-to-`${{`, got {out:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Companion: arbitrary high-entropy bytes in an extra-env value
|
||||
/// must also pass through verbatim. (Sanity check that the
|
||||
/// per-call sentinel doesn't collide with random binary content.)
|
||||
#[test]
|
||||
fn expand_preserves_arbitrary_bytes_in_extra() {
|
||||
let mut extra = HashMap::new();
|
||||
// A mix of printable ASCII, NULs, PUA chars, brace bytes, and
|
||||
// dollar signs -- the kinds of bytes most likely to clash
|
||||
// with any future sentinel scheme.
|
||||
let exotic = "\u{0}\u{f8ff}${weird}}\u{f8ff}\u{0}__MASK__";
|
||||
extra.insert("VAL".to_string(), exotic.to_string());
|
||||
let out = expand_env_vars_with_extra("X=${VAL}", &extra);
|
||||
assert_eq!(out, format!("X={exotic}"));
|
||||
}
|
||||
|
||||
// ── iter_env_var_references unit tests ───────────────────────
|
||||
|
||||
/// Lock down the iterator output for a single braced plain form.
|
||||
#[test]
|
||||
fn iter_yields_plain_braced_form() {
|
||||
let refs: Vec<_> = iter_env_var_references("foo ${BAR} baz").collect();
|
||||
assert_eq!(refs.len(), 1);
|
||||
assert_eq!(refs[0].name, "BAR");
|
||||
assert!(refs[0].braced);
|
||||
assert!(!refs[0].has_modifier);
|
||||
assert_eq!(refs[0].start, 4);
|
||||
assert_eq!(refs[0].end, 10);
|
||||
}
|
||||
|
||||
/// Lock down the iterator output for a single bare form.
|
||||
#[test]
|
||||
fn iter_yields_bare_form() {
|
||||
let refs: Vec<_> = iter_env_var_references("foo $BAR baz").collect();
|
||||
assert_eq!(refs.len(), 1);
|
||||
assert_eq!(refs[0].name, "BAR");
|
||||
assert!(!refs[0].braced);
|
||||
assert!(!refs[0].has_modifier);
|
||||
assert_eq!(refs[0].start, 4);
|
||||
assert_eq!(refs[0].end, 8);
|
||||
}
|
||||
|
||||
/// Modifier form sets has_modifier = true.
|
||||
#[test]
|
||||
fn iter_flags_modifier_form() {
|
||||
let refs: Vec<_> = iter_env_var_references("${VAR:-x}").collect();
|
||||
assert_eq!(refs.len(), 1);
|
||||
assert_eq!(refs[0].name, "VAR");
|
||||
assert!(refs[0].braced);
|
||||
assert!(refs[0].has_modifier);
|
||||
assert_eq!(refs[0].start, 0);
|
||||
assert_eq!(refs[0].end, 9);
|
||||
}
|
||||
|
||||
/// Shell positionals / specials / command substitutions are NOT
|
||||
/// yielded.
|
||||
#[test]
|
||||
fn iter_skips_shell_specials() {
|
||||
let refs: Vec<_> = iter_env_var_references("$1 $$ $? $# $(date) $@").collect();
|
||||
assert!(
|
||||
refs.is_empty(),
|
||||
"shell special params must not yield refs, got {refs:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Unterminated braced form: the `$` is consumed; nothing yielded.
|
||||
#[test]
|
||||
fn iter_skips_unterminated_brace() {
|
||||
let refs: Vec<_> = iter_env_var_references("${VAR:-no-close").collect();
|
||||
assert!(refs.is_empty(), "unterminated brace must yield no refs");
|
||||
}
|
||||
|
||||
/// Empty / invalid identifier inside braces: yielded with empty
|
||||
/// name and has_modifier=false.
|
||||
#[test]
|
||||
fn iter_yields_invalid_braced_form_with_empty_name() {
|
||||
let refs: Vec<_> = iter_env_var_references("${:-foo}").collect();
|
||||
assert_eq!(refs.len(), 1);
|
||||
assert_eq!(refs[0].name, "");
|
||||
assert!(refs[0].braced);
|
||||
assert!(
|
||||
!refs[0].has_modifier,
|
||||
"invalid form (no identifier) must not be flagged as a modifier form"
|
||||
);
|
||||
}
|
||||
|
||||
/// Mixed input: plain, modifier, bare, and a positional.
|
||||
#[test]
|
||||
fn iter_yields_mixed_forms_in_order() {
|
||||
let refs: Vec<_> = iter_env_var_references("${A}${B:-x}$C $1").collect();
|
||||
assert_eq!(refs.len(), 3);
|
||||
assert_eq!(refs[0].name, "A");
|
||||
assert!(refs[0].braced && !refs[0].has_modifier);
|
||||
assert_eq!(refs[1].name, "B");
|
||||
assert!(refs[1].braced && refs[1].has_modifier);
|
||||
assert_eq!(refs[2].name, "C");
|
||||
assert!(!refs[2].braced && !refs[2].has_modifier);
|
||||
}
|
||||
|
||||
/// Nested braces are matched at the FIRST `}` (legacy parser
|
||||
/// behaviour, see `mask_helper_nested_braces_in_modifier_body`).
|
||||
#[test]
|
||||
fn iter_matches_first_closing_brace_for_nested() {
|
||||
// Bytes: `${A:-${B}}` (indices 0..10).
|
||||
// The outer ref begins at the leading `$` (0), reads `A` as
|
||||
// the identifier, sees `:` as the first non-identifier byte,
|
||||
// then walks forward to the FIRST `}` -- which is the closing
|
||||
// brace of the inner `${B}` at index 8. So end = 9. The
|
||||
// trailing `}` at index 9 is literal text.
|
||||
let refs: Vec<_> = iter_env_var_references("${A:-${B}}").collect();
|
||||
assert_eq!(refs.len(), 1);
|
||||
assert_eq!(refs[0].name, "A");
|
||||
assert!(refs[0].braced);
|
||||
assert!(refs[0].has_modifier);
|
||||
assert_eq!(refs[0].start, 0);
|
||||
assert_eq!(refs[0].end, 9);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user