Files
ai-memory/crates/ai-memory-cli/tests/suite/packaging.rs
T

2811 lines
102 KiB
Rust

//! Packaging asset regression tests.
#[cfg(unix)]
use std::io::BufRead as _;
use std::path::{Path, PathBuf};
#[cfg(any(unix, windows))]
use std::process::Command;
#[cfg(unix)]
use std::process::Stdio;
#[cfg(unix)]
fn sha256_file(path: &Path) -> String {
use sha2::{Digest as _, Sha256};
format!("{:x}", Sha256::digest(std::fs::read(path).unwrap()))
}
fn repo_root() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.and_then(Path::parent)
.expect("crate should live under crates/ai-memory-cli")
.to_path_buf()
}
fn read_repo(path: &str) -> String {
let path = repo_root().join(path);
std::fs::read_to_string(&path)
.unwrap_or_else(|e| panic!("failed to read {}: {e}", path.display()))
.replace("\r\n", "\n")
}
// Unix-only alongside run_wrapper_on_fake_macos below — these helpers'
// former Git Bash arms existed to run the wrapper test on Windows, which
// the fake-uname executable-bit limitation rules out anyway.
#[cfg(unix)]
fn shell_script_command(script: &Path) -> Command {
Command::new(script)
}
#[cfg(unix)]
// Preserve the script path as `$0` without directly execing a just-written file,
// which can transiently return ETXTBSY under parallel Linux test load.
fn freshly_written_shell_script_command(script: &Path) -> Command {
let mut command = Command::new("bash");
command.arg(script);
command
}
#[cfg(unix)]
fn shell_path(path: &Path) -> String {
path.display().to_string()
}
// Unix-only: the macOS simulation works by shadowing `uname` with a fake
// script earlier in PATH, which requires setting its executable bit. NTFS
// has no mode bits, so on a Windows host MSYS bash skips the non-executable
// fake and the real `uname.exe` reports MSYS_NT-* — the Darwin arm under
// test can never fire there.
#[cfg(unix)]
fn run_wrapper_on_fake_macos(args: &[&str]) -> String {
let tmp = tempfile::tempdir().unwrap();
let docker_args = tmp.path().join("docker-args.txt");
let docker = tmp.path().join("docker");
let uname = tmp.path().join("uname");
std::fs::write(
&docker,
format!(
"#!/usr/bin/env bash\nprintf '%s\\n' \"$@\" > {}\n",
shell_path(&docker_args)
),
)
.unwrap();
std::fs::write(&uname, "#!/usr/bin/env bash\nprintf 'Darwin\\n'\n").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&docker, std::fs::Permissions::from_mode(0o755)).unwrap();
std::fs::set_permissions(&uname, std::fs::Permissions::from_mode(0o755)).unwrap();
}
let path = format!(
"{}:{}",
shell_path(tmp.path()),
std::env::var("PATH").unwrap_or_default()
);
let mut command = shell_script_command(&repo_root().join("bin/ai-memory"));
let output = command
.args(args)
.env("PATH", path)
.env("AI_MEMORY_DOCKER", shell_path(&docker))
.env("AI_MEMORY_NO_VERSION_CHECK", "1")
.env("AI_MEMORY_DATA_VOLUME", "test-ai-memory-data")
.env("HOME", shell_path(tmp.path()))
.env_remove("AI_MEMORY_SERVER_URL")
.env_remove("CLAUDE_CONFIG_DIR")
.output()
.unwrap();
assert!(
output.status.success(),
"wrapper failed: stdout={} stderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
std::fs::read_to_string(docker_args).unwrap()
}
#[test]
fn systemd_units_use_explicit_native_paths() {
let system = read_repo("packaging/systemd/ai-memory.service");
assert!(system.contains("--data-dir /var/lib/ai-memory"));
assert!(system.contains("--config /etc/ai-memory/config.toml"));
assert!(system.contains("EnvironmentFile=-/etc/ai-memory/env"));
assert!(system.contains("StateDirectory=ai-memory"));
assert!(system.contains("ReadWritePaths=/var/lib/ai-memory"));
assert!(!system.contains("/var/local"));
let user = read_repo("packaging/systemd/ai-memory-user.service");
assert!(user.contains("--data-dir %h/.local/share/ai-memory"));
assert!(user.contains("--config %h/.config/ai-memory/config.toml"));
assert!(user.contains("EnvironmentFile=-%h/.config/ai-memory/env"));
assert!(!user.contains("/var/lib/ai-memory"));
}
const LAUNCHD_AGENT_PLIST: &str = "packaging/launchd/com.github.akitaonrails.ai-memory.plist";
#[test]
fn launchd_agent_plist_is_home_independent_and_unthrottled() {
let agent = read_repo(LAUNCHD_AGENT_PLIST);
// launchd expands nothing: no %h, no $HOME, no ~. Every path in a plist is
// a literal, so the two the template cannot know stay placeholders.
assert!(agent.contains("__AI_MEMORY_BIN__"));
assert!(agent.contains("__HOME__/Library/Logs/ai-memory/"));
for unexpandable in ["%h", "$HOME", "${HOME}", "~/"] {
assert!(
!agent.contains(unexpandable),
"launchd would take {unexpandable} literally instead of expanding it"
);
}
// The macOS data dir and the config file inside it are already the binary's
// defaults, so passing them would only reintroduce a home-dependent path.
assert!(!agent.contains("--data-dir"));
assert!(!agent.contains("--config"));
// An unset ProcessType makes launchd throttle CPU and I/O bandwidth, which
// the hook ingress budget cannot absorb. Adaptive keys off XPC
// transactions, which ai-memory never opens, so it decays to Background.
assert!(agent.contains("<key>ProcessType</key>\n <string>Interactive</string>"));
assert!(agent.contains("<key>RunAtLoad</key>\n <true/>"));
assert!(agent.contains("<key>KeepAlive</key>\n <true/>"));
// launchctl addresses the job by Label, so a Label that disagrees with the
// filename silently invalidates every bootout/kickstart command we publish.
let label = Path::new(LAUNCHD_AGENT_PLIST)
.file_stem()
.and_then(|stem| stem.to_str())
.expect("the plist path has a file stem");
assert!(agent.contains(&format!("<key>Label</key>\n <string>{label}</string>")));
}
// Substring assertions cannot see malformed XML, and a plist launchd refuses to
// parse fails at bootstrap time with no useful diagnostic.
#[cfg(target_os = "macos")]
#[test]
fn launchd_agent_plist_is_a_valid_property_list() {
let path = repo_root().join(LAUNCHD_AGENT_PLIST);
let output = Command::new("plutil")
.arg("-lint")
.arg(&path)
.output()
.unwrap();
assert!(
output.status.success(),
"plutil -lint rejected {}: {}{}",
path.display(),
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
#[test]
fn macos_release_tarball_ships_the_launchd_agent_plist() {
let release = read_repo(".github/workflows/release.yml");
assert!(release.contains("cp -a packaging/launchd \"dist/$artifact/packaging/launchd\""));
assert!(
release.contains(LAUNCHD_AGENT_PLIST),
"the macOS tarball smoke test must prove the plist actually shipped"
);
}
#[test]
fn aur_packages_install_all_native_assets() {
for path in ["packaging/aur/PKGBUILD", "packaging/aur/PKGBUILD-bin"] {
let pkgbuild = read_repo(path);
assert!(pkgbuild.contains("/usr/bin/ai-memory"), "{path}");
assert!(pkgbuild.contains("/usr/share/ai-memory"), "{path}");
assert!(
pkgbuild.contains("/usr/lib/systemd/system/ai-memory.service"),
"{path}"
);
assert!(
pkgbuild.contains("/usr/lib/systemd/user/ai-memory.service"),
"{path}"
);
assert!(
pkgbuild.contains("/usr/lib/sysusers.d/ai-memory.conf"),
"{path}"
);
assert!(
pkgbuild.contains("/usr/lib/tmpfiles.d/ai-memory.conf"),
"{path}"
);
assert!(pkgbuild.contains("etc/ai-memory/config.toml"), "{path}");
assert!(pkgbuild.contains("etc/ai-memory/env"), "{path}");
assert!(
pkgbuild.contains("install -Dm0640 packaging/env/ai-memory.env"),
"{path}"
);
}
// The from-source PKGBUILD builds and runs `check()` on the AUR host.
// Two things keep that green (see #677): `!lto` avoids the release-LTO
// link step that OOM-killed the build on constrained AUR builders, and
// pinning CARGO_HOME to the real registry before the HOME override lets
// the `--frozen` check() resolve the packages build() already fetched.
let src_pkgbuild = read_repo("packaging/aur/PKGBUILD");
assert!(
src_pkgbuild.contains("options=('!debug' '!lto')"),
"from-source PKGBUILD must disable LTO to survive constrained AUR builders"
);
let cargo_home = src_pkgbuild
.find("export CARGO_HOME=")
.expect("check() must pin CARGO_HOME");
let home_override = src_pkgbuild
.find(r#"export HOME="$srcdir/test-home""#)
.expect("check() must override HOME");
assert!(
cargo_home < home_override,
"CARGO_HOME must be pinned to the real registry before HOME is repointed, \
or --frozen check() cannot resolve the fetched packages"
);
let install = read_repo("packaging/aur/ai-memory.install");
assert!(install.contains("sudo -u ai-memory ai-memory --data-dir /var/lib/ai-memory"));
assert!(!install.contains("sudo ai-memory --data-dir /var/lib/ai-memory"));
let bin_pkgbuild = read_repo("packaging/aur/PKGBUILD-bin");
assert!(bin_pkgbuild.contains("source_x86_64"));
assert!(bin_pkgbuild.contains("source_aarch64"));
assert!(bin_pkgbuild.contains("linux-x86_64.tar.gz"));
assert!(bin_pkgbuild.contains("linux-aarch64.tar.gz"));
}
#[test]
fn docker_source_build_uses_vendored_tailwind() {
let dockerfile = read_repo("docker/Dockerfile");
assert!(dockerfile.contains("cargo build --locked --release -p ai-memory-cli"));
assert!(
!dockerfile.contains("TAILWIND"),
"the vendored stylesheet is the default; the image build must not opt into a regeneration"
);
}
#[test]
fn docker_context_excludes_operator_deployment_files() {
let dockerignore = read_repo(".dockerignore");
let protected_suffix = concat!(
"# Operator-specific deployment files. Keep this block last so a later negation\n",
"# cannot re-include credentials or host configuration in the build context.\n",
"/bin/deploy.env\n",
"/docker/.env.production\n",
"/docker/docker-compose.prod.yml\n",
);
assert!(
dockerignore.ends_with(protected_suffix),
"operator deployment exclusions must remain the final Docker ignore rules"
);
}
#[test]
fn docker_publish_jobs_use_prebuilt_binaries() {
let dockerfile = read_repo("docker/Dockerfile");
assert!(dockerfile.contains("FROM runtime-base AS runtime-prebuilt-amd64"));
assert!(dockerfile.contains("FROM runtime-base AS runtime-prebuilt-arm64"));
assert!(dockerfile.contains("dist/docker/ai-memory-linux-x86_64/ai-memory"));
assert!(dockerfile.contains("dist/docker/ai-memory-linux-aarch64/ai-memory"));
let release = read_repo(".github/workflows/release.yml");
assert!(release.contains("artifact: ai-memory-linux-x86_64"));
assert!(release.contains("artifact: ai-memory-linux-aarch64"));
assert!(release.contains("artifact: ai-memory-macos-aarch64"));
assert!(release.contains("artifact: ai-memory-macos-x86_64"));
assert!(release.contains("needs: [binary, macos, windows, validate-version]"));
assert!(release.contains("target: runtime-prebuilt-amd64"));
assert!(release.contains("target: runtime-prebuilt-arm64"));
let ci = read_repo(".github/workflows/ci.yml");
assert!(ci.contains("ci-ai-memory-${{ matrix.artifact }}"));
// The release-build matrix is a conditional expression since the
// fast-CI split: Linux always, the macOS legs on full-ci/dispatch.
// The full branch must keep every release artifact and runner.
assert!(ci.contains(r#"{"artifact": "linux-x86_64", "runner": "ubuntu-22.04"}"#));
assert!(ci.contains(r#"{"artifact": "macos-aarch64", "runner": "macos-15"}"#));
assert!(ci.contains(r#"{"artifact": "macos-x86_64", "runner": "macos-15-intel"}"#));
// and the reduced branch still builds the Linux artifact docker uses
assert!(
ci.matches(r#"{"artifact": "linux-x86_64", "runner": "ubuntu-22.04"}"#)
.count()
>= 2
);
assert!(ci.contains("--target runtime-prebuilt-amd64"));
}
// Like run_wrapper_on_fake_macos's fake docker, but the wrapper is spawned with
// stdin on a pipe: the shape every `cat page.md | ai-memory write-page --body -`
// (and every CI/cron) invocation actually has.
#[cfg(unix)]
fn run_wrapper_with_piped_stdin(args: &[&str], stdin_payload: &str) -> String {
use std::io::Write as _;
let tmp = tempfile::tempdir().unwrap();
let docker_args = tmp.path().join("docker-args.txt");
let docker = tmp.path().join("docker");
std::fs::write(
&docker,
format!(
"#!/usr/bin/env bash\nprintf '%s\\n' \"$@\" > {}\n",
shell_path(&docker_args)
),
)
.unwrap();
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&docker, std::fs::Permissions::from_mode(0o755)).unwrap();
}
let mut child = shell_script_command(&repo_root().join("bin/ai-memory"))
.args(args)
.env("AI_MEMORY_DOCKER", shell_path(&docker))
.env("AI_MEMORY_NO_VERSION_CHECK", "1")
.env("AI_MEMORY_DATA_VOLUME", "test-ai-memory-data")
.env("HOME", shell_path(tmp.path()))
.env_remove("AI_MEMORY_SERVER_URL")
.env_remove("CLAUDE_CONFIG_DIR")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
child
.stdin
.take()
.expect("stdin was piped")
.write_all(stdin_payload.as_bytes())
.unwrap();
let output = child.wait_with_output().unwrap();
assert!(
output.status.success(),
"wrapper failed: stdout={} stderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
std::fs::read_to_string(docker_args).unwrap()
}
#[test]
fn docker_wrappers_keep_stdin_attached_independently_of_tty_allocation() {
let posix = read_repo("bin/ai-memory");
assert!(
posix.contains("TTY_ARGS=(-i)"),
"POSIX wrapper must attach stdin before inspecting terminal state"
);
assert!(
posix.contains("TTY_ARGS+=(-t)"),
"POSIX wrapper must add TTY allocation separately"
);
assert!(
!posix.contains("TTY_ARGS=(-it)"),
"combined flags can drop stdin when only stdout is redirected"
);
assert!(
posix.contains("CLAUDE_CODE_SESSION_ID"),
"POSIX wrapper must forward Claude's session id into the bridge container"
);
assert!(posix.contains("git -C \"${PWD}\" rev-parse --show-toplevel"));
assert!(posix.contains("AI_MEMORY_SCOPE_CWD=/scope${SCOPE_REL}"));
assert!(posix.contains("${SCOPE_ROOT}:/scope:ro"));
let powershell = read_repo("bin/ai-memory.ps1");
assert!(
powershell.contains("$DockerArgs = @(\"run\", \"--rm\", \"-i\")"),
"PowerShell wrapper must attach stdin before inspecting console state"
);
assert!(
powershell.contains("$DockerArgs += \"-t\""),
"PowerShell wrapper must add TTY allocation separately"
);
assert!(
!powershell.contains("$DockerArgs += \"-it\""),
"PowerShell must not couple stdin attachment to TTY allocation"
);
assert!(
powershell.contains("\"CLAUDE_CODE_SESSION_ID\""),
"PowerShell wrapper must forward Claude's session id into the bridge container"
);
assert!(powershell.contains("AI_MEMORY_SCOPE_CWD=$ScopeCwd"));
assert!(powershell.contains("${ScopeRoot}:/scope:ro"));
}
#[cfg(unix)]
#[test]
fn posix_wrapper_auto_selects_podman_when_docker_is_unavailable() {
let tmp = tempfile::tempdir().unwrap();
let podman_args = tmp.path().join("podman-args.txt");
let scripts = [
(
"podman",
format!(
"#!/bin/bash\n\
if [ \"${{1:-}}\" = info ]; then\n\
\x20 printf '[name=seccomp]\\n'\n\
\x20 exit 0\n\
fi\n\
printf '%s\\n' \"$@\" > {}\n",
shell_path(&podman_args)
),
),
(
"id",
"#!/bin/bash\ncase \"$1\" in -u) printf '1000\\n' ;; -g) printf '1000\\n' ;; esac\n"
.to_owned(),
),
("uname", "#!/bin/bash\nprintf 'Linux\\n'\n".to_owned()),
(
"getenforce",
"#!/bin/bash\nprintf 'Disabled\\n'\n".to_owned(),
),
(
"grep",
"#!/bin/bash\n\
pattern=\"${@: -1}\"\n\
input=\"\"\n\
while IFS= read -r line; do input=\"${input}${line}\"; done\n\
case \"${input}\" in *\"${pattern}\"*) exit 0 ;; *) exit 1 ;; esac\n"
.to_owned(),
),
];
for (name, body) in scripts {
let path = tmp.path().join(name);
std::fs::write(&path, body).unwrap();
use std::os::unix::fs::PermissionsExt as _;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap();
}
let output = Command::new("/bin/bash")
.arg(repo_root().join("bin/ai-memory"))
.arg("status")
.env("PATH", tmp.path())
.env("HOME", tmp.path())
.env("AI_MEMORY_NO_TTY", "1")
.env("AI_MEMORY_NO_VERSION_CHECK", "1")
.env("AI_MEMORY_DATA_VOLUME", "test-ai-memory-data")
.env_remove("AI_MEMORY_DOCKER")
.env_remove("AI_MEMORY_IMAGE")
.env_remove("AI_MEMORY_SERVER_URL")
.output()
.unwrap();
assert!(
output.status.success(),
"wrapper failed: stdout={} stderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let args = std::fs::read_to_string(podman_args).unwrap();
let flags: Vec<_> = args.lines().collect();
assert_eq!(
flags.first(),
Some(&"run"),
"Podman was not invoked: {args}"
);
assert!(
flags.contains(&"docker.io/akitaonrails/ai-memory:latest"),
"default image must not require Podman short-name resolution: {args}"
);
}
const WRAPPER_FORWARDED_PROVIDER_KEYS: &[&str] = &[
"ANTHROPIC_API_KEY",
"ANTHROPIC_OAUTH_TOKEN",
"OPENAI_API_KEY",
"GEMINI_API_KEY",
"GOOGLE_API_KEY",
"VOYAGE_API_KEY",
"COPILOT_GITHUB_TOKEN",
"GITHUB_COPILOT_API_TOKEN",
"COPILOT_API_URL",
"LLM_API_KEY",
"EMBEDDING_API_KEY",
"OPENCODE_API_KEY",
];
#[test]
fn wrapper_forwards_every_supported_provider_api_key() {
// The wrapper runs the server in a container, so any provider credential
// the operator exports must be on the `-e` forwarding allowlist or it
// never reaches the process and the provider reports "not configured".
// Gemini/Google were missing while every other provider key was
// forwarded (#698), so the guard names each key the config layer reads.
let wrapper = read_repo("bin/ai-memory");
for key in WRAPPER_FORWARDED_PROVIDER_KEYS {
assert!(
wrapper.contains(&format!(" {key} \\")),
"wrapper must forward {key} into the container"
);
}
}
#[test]
fn powershell_wrapper_forwards_every_supported_provider_api_key() {
// Same contract as the POSIX wrapper: Docker Desktop on Windows still
// runs the CLI inside a Linux helper, so a host-exported Gemini /
// Copilot / OpenCode key that is not on this allowlist never reaches
// Config::load and the provider reports "not configured".
let wrapper = read_repo("bin/ai-memory.ps1");
let allowlist = wrapper
.split_once("foreach ($Name in @(")
.and_then(|(_, rest)| rest.split_once(")) {"))
.map(|(allowlist, _)| allowlist)
.expect("PowerShell wrapper env allowlist");
for key in WRAPPER_FORWARDED_PROVIDER_KEYS {
assert!(
allowlist
.lines()
.any(|line| line.trim() == format!("\"{key}\",")
|| line.trim() == format!("\"{key}\"")),
"PowerShell wrapper must list {key} in the helper env allowlist"
);
}
for key in [
"AI_MEMORY_COPILOT_CLIENT_ID",
"AI_MEMORY_WORKSTREAM_ID",
"CLAUDE_CONFIG_DIR",
] {
assert!(
allowlist
.lines()
.any(|line| line.trim() == format!("\"{key}\",")
|| line.trim() == format!("\"{key}\"")),
"PowerShell wrapper must list {key} in the helper env allowlist"
);
}
}
#[test]
fn wrapper_updates_and_install_docs_use_verified_release_assets() {
let wrapper = read_repo("bin/ai-memory");
assert!(wrapper.contains("releases/latest/download/ai-memory-wrapper"));
assert!(wrapper.contains("WRAPPER_SHA256_URL"));
assert!(wrapper.contains("wrapper checksum mismatch; refusing update"));
assert!(!wrapper.contains("raw.githubusercontent.com/akitaonrails/ai-memory/main"));
let release = read_repo(".github/workflows/release.yml");
for asset in [
"ai-memory-wrapper",
"ai-memory-wrapper.ps1",
"ai-memory-wrapper.cmd",
"ai-memory-install-hooks",
"ai-memory-hooks.tar.gz",
] {
assert!(release.contains(asset), "release must publish {asset}");
assert!(
release.contains(&format!("{asset}.sha256")),
"release must publish a checksum for {asset}"
);
}
assert!(release.contains("permissions:\n contents: read"));
assert!(release.contains("github-release:"));
assert!(release.contains("contents: write"));
for path in ["README.md", "docs/install.md", "docs/windows.md"] {
let docs = read_repo(path);
assert!(
!docs.contains("raw.githubusercontent.com/akitaonrails/ai-memory/main/bin/ai-memory"),
"{path} must not install an executable from mutable main"
);
}
let install_docs = read_repo("docs/install.md");
assert!(
!install_docs.contains("raw.githubusercontent.com/akitaonrails/ai-memory/main/scripts"),
"hook installation docs must not execute mutable main"
);
let hook_installer = read_repo("scripts/install-hooks.sh");
assert!(hook_installer.contains("ARCHIVE=\"ai-memory-hooks.tar.gz\""));
assert!(hook_installer.contains("$BASE_URL/$ARCHIVE.sha256"));
assert!(hook_installer.contains("hook bundle checksum mismatch; refusing installation"));
assert!(hook_installer.contains("tar -xOf"));
assert!(!hook_installer.contains("tar -xzf"));
assert!(!hook_installer.contains("raw.githubusercontent.com"));
}
#[test]
fn github_actions_are_pinned_to_full_commits() {
for path in [".github/workflows/ci.yml", ".github/workflows/release.yml"] {
let workflow = read_repo(path);
for line in workflow.lines().filter(|line| line.contains("uses: ")) {
let Some(reference) = line
.split('@')
.nth(1)
.and_then(|value| value.split_whitespace().next())
else {
continue;
};
assert!(
reference.len() == 40 && reference.chars().all(|c| c.is_ascii_hexdigit()),
"{path} action is not pinned to a full commit: {line}"
);
}
}
}
#[test]
fn workflows_keep_fixed_rust_jobs_on_the_fixed_toolchain() {
for (path, expected_fixed_jobs) in [
(".github/workflows/ci.yml", 2),
(".github/workflows/release.yml", 3),
] {
let workflow = read_repo(path);
let lines = workflow.lines().collect::<Vec<_>>();
let mut fixed_jobs = 0;
for (index, line) in lines.iter().enumerate().filter(|(_, line)| {
line.contains("uses: dtolnay/rust-toolchain@") && line.ends_with("# 1.95")
}) {
fixed_jobs += 1;
assert_eq!(
lines.get(index + 1).map(|line| line.trim()),
Some("with:"),
"{path} must configure the fixed toolchain after: {line}"
);
assert_eq!(
lines.get(index + 2).map(|line| line.trim()),
Some("toolchain: \"1.95\""),
"{path} must keep its # 1.95 job on Rust 1.95"
);
}
assert_eq!(
fixed_jobs, expected_fixed_jobs,
"{path} has an unexpected number of fixed Rust jobs"
);
}
}
#[test]
fn dev_loop_is_wired_consistently() {
// One gate, written down in four places; keep them from drifting apart.
let ci = read_repo(".github/workflows/ci.yml");
assert!(ci.contains("cargo test --workspace --all-targets"));
assert!(
ci.contains("TAILWIND_BUILD=1"),
"CI must regenerate the stylesheet somewhere"
);
assert!(ci.contains("git diff --exit-code -- crates/ai-memory-web/static/tailwind.css"));
let release = read_repo("bin/release");
assert!(release.contains("git diff --check"));
assert!(release.contains("cargo test --workspace --all-targets"));
let pr_template = read_repo(".github/pull_request_template.md");
assert!(pr_template.contains("git diff --check"));
assert!(pr_template.contains("cargo tf"));
let contributing = read_repo("CONTRIBUTING.md");
assert!(contributing.contains("cargo tf"));
assert!(contributing.contains("install-git-hooks.sh"));
let installer = read_repo("scripts/install-git-hooks.sh");
assert!(installer.contains("# >>> ai-memory pre-push >>>"));
assert!(
!installer.contains("cat > \"$hook\""),
"the hook installer must not overwrite an existing user hook"
);
// `.cargo/config.toml` carries the tier aliases and nothing else. An env
// table there applies to every cargo invocation on every platform.
let cargo_config = read_repo(".cargo/config.toml");
assert!(
!cargo_config.contains("[env]"),
"keep .cargo/config.toml to aliases"
);
for alias in ["t = ", "tf = "] {
assert!(
cargo_config.contains(alias),
"missing cargo alias {alias:?}"
);
}
// The build is self-contained: nothing should still ask for the old skip flag.
for path in [
".github/workflows/ci.yml",
".github/workflows/release.yml",
".github/workflows/windows.yml",
"docker/Dockerfile",
"bin/release",
"AGENTS.md",
"CONTRIBUTING.md",
".github/pull_request_template.md",
] {
assert!(
!read_repo(path).contains("TAILWIND_SKIP"),
"{path} still sets TAILWIND_SKIP"
);
}
}
#[cfg(unix)]
fn installed_hook_names(agent_arg: &str, canonical_agent: &str, hooks: &[&str]) -> Vec<String> {
let tmp = tempfile::tempdir().unwrap();
let bundle = tmp.path().join("bundle/hooks").join(canonical_agent);
std::fs::create_dir_all(&bundle).unwrap();
for hook in hooks {
std::fs::write(
bundle.join(format!("{hook}.sh")),
format!("#!/usr/bin/env bash\nprintf '{hook}\\n'\n"),
)
.unwrap();
}
let archive = tmp.path().join("ai-memory-hooks.tar.gz");
let status = Command::new("tar")
.arg("-czf")
.arg(&archive)
.arg("-C")
.arg(tmp.path().join("bundle"))
.arg("hooks")
.status()
.unwrap();
assert!(status.success());
let bin_dir = tmp.path().join("bin");
std::fs::create_dir_all(&bin_dir).unwrap();
let curl = bin_dir.join("curl");
std::fs::write(
&curl,
"#!/usr/bin/env bash\n\
set -euo pipefail\n\
url=''\n\
out=''\n\
while [ \"$#\" -gt 0 ]; do\n\
case \"$1\" in\n\
-o) out=\"$2\"; shift 2 ;;\n\
-*) shift ;;\n\
*) url=\"$1\"; shift ;;\n\
esac\n\
done\n\
case \"$url\" in\n\
*.sha256) printf '%s ai-memory-hooks.tar.gz\\n' \"$FAKE_HOOK_CHECKSUM\" > \"$out\" ;;\n\
*) cp \"$FAKE_HOOK_ARCHIVE\" \"$out\" ;;\n\
esac\n",
)
.unwrap();
{
use std::os::unix::fs::PermissionsExt as _;
std::fs::set_permissions(&curl, std::fs::Permissions::from_mode(0o755)).unwrap();
}
let path = format!(
"{}:{}",
shell_path(&bin_dir),
std::env::var("PATH").unwrap_or_default()
);
let destination = tmp.path().join("installed-hooks");
let output = shell_script_command(&repo_root().join("scripts/install-hooks.sh"))
.args(["--agent", agent_arg, "--to"])
.arg(&destination)
.env("PATH", path)
.env("HOME", tmp.path())
.env("FAKE_HOOK_ARCHIVE", &archive)
.env("FAKE_HOOK_CHECKSUM", sha256_file(&archive))
.output()
.unwrap();
assert!(
output.status.success(),
"installer failed: stdout={} stderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let installed = destination.join(canonical_agent);
let mut names = std::fs::read_dir(&installed)
.unwrap()
.map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())
.collect::<Vec<_>>();
names.sort();
use std::os::unix::fs::PermissionsExt as _;
for name in &names {
assert_ne!(
std::fs::metadata(installed.join(name))
.unwrap()
.permissions()
.mode()
& 0o111,
0
);
}
names
}
// Unlike run_wrapper_on_fake_macos's docker fake (which only ever sees one
// meaningful call — the final `docker run`), the rootless-Docker UID check
// calls `docker info` *before* `docker run`, so this fake must dispatch on
// $1: real stdout for `info` (read by the wrapper's `grep -q rootless`) vs.
// logging argv to a file for `run` (read back by the test).
// How the fake `docker` binary answers `docker info --format …`. The two
// engines expose the same two facts under incompatible keys, and the wrapper
// has to read both, so the fake has to be able to impersonate either one.
#[cfg(unix)]
enum FakeInfo<'a> {
// Docker: `{{.SecurityOptions}}` prints the security-options list.
Docker(&'a str),
// Podman, including the podman-docker `docker` shim: `.SecurityOptions`
// is not a field of its info report, so the template fails and the
// command exits non-zero with nothing on stdout. The equivalent facts
// live under `.Host.Security.*`.
Podman { rootless: bool, selinux: bool },
}
#[cfg(unix)]
fn run_wrapper_with_fake_docker(args: &[&str], docker_info_stdout: &str) -> String {
run_wrapper_with_fake_docker_and_uname(args, docker_info_stdout, None)
}
#[cfg(unix)]
fn run_wrapper_with_fake_docker_and_claude_config(
args: &[&str],
docker_info_stdout: &str,
claude_config_dir: &str,
) -> String {
run_wrapper_with_fake_docker_env(
args,
FakeInfo::Docker(docker_info_stdout),
None,
Some(claude_config_dir),
None,
&[],
)
}
#[cfg(unix)]
fn run_wrapper_with_fake_docker_and_forwarded_env(
args: &[&str],
docker_info_stdout: &str,
forwarded_env: &[(&str, &str)],
) -> String {
run_wrapper_with_fake_docker_env(
args,
FakeInfo::Docker(docker_info_stdout),
None,
None,
None,
forwarded_env,
)
}
// The wrapper also shells out to `id -u` / `id -g` when choosing its default
// Docker uid mapping. Arch container tests often run as root, which would make
// the default mapping `-u 0:0` and produce a false positive in the assertions
// below. Shadow `id` too so these tests exercise the rootless/rootful branch
// logic, not the uid of the test runner. This shadow is unconditional
// (unlike `uname`, which only matters for the macOS-simulation callers)
// because every caller of this helper is exposed to the flakiness.
#[cfg(unix)]
fn run_wrapper_with_fake_docker_and_uname(
args: &[&str],
docker_info_stdout: &str,
uname_stdout: Option<&str>,
) -> String {
run_wrapper_with_fake_docker_env(
args,
FakeInfo::Docker(docker_info_stdout),
uname_stdout,
None,
None,
&[],
)
}
#[cfg(unix)]
fn run_wrapper_with_fake_selinux(
args: &[&str],
docker_info_stdout: &str,
selinux_mode: &str,
) -> String {
run_wrapper_with_fake_docker_env(
args,
FakeInfo::Docker(docker_info_stdout),
Some("Linux"),
None,
Some(selinux_mode),
&[],
)
}
// Rootless podman on an SELinux-enforcing host — the combination Fedora and
// openSUSE ship by default, and the one where the Docker-only probe answers
// nothing at all.
#[cfg(unix)]
fn run_wrapper_with_fake_podman(
args: &[&str],
rootless: bool,
selinux: bool,
selinux_mode: &str,
) -> String {
run_wrapper_with_fake_docker_env(
args,
FakeInfo::Podman { rootless, selinux },
Some("Linux"),
None,
Some(selinux_mode),
&[],
)
}
#[cfg(unix)]
fn run_wrapper_with_fake_docker_env(
args: &[&str],
fake_info: FakeInfo<'_>,
uname_stdout: Option<&str>,
claude_config_dir: Option<&str>,
selinux_mode: Option<&str>,
forwarded_env: &[(&str, &str)],
) -> String {
let tmp = tempfile::tempdir().unwrap();
let docker_args = tmp.path().join("docker-args.txt");
let docker = tmp.path().join("docker");
let uname = tmp.path().join("uname");
let id = tmp.path().join("id");
let getenforce = tmp.path().join("getenforce");
let info_branch = match fake_info {
FakeInfo::Docker(stdout) => format!(" printf '%s\\n' '{stdout}'\n exit 0\n"),
FakeInfo::Podman { rootless, selinux } => format!(
" case \"$*\" in\n\
\x20 *Host.Security.Rootless*) printf '{rootless}\\n' ; exit 0 ;;\n\
\x20 *Host.Security.SELinuxEnabled*) printf '{selinux}\\n' ; exit 0 ;;\n\
\x20 esac\n\
\x20 printf '%s\\n' \"Error: template: info:1:2: executing \\\"info\\\" at \
<.SecurityOptions>: can't evaluate field SecurityOptions in \
type system.infoReport\" >&2\n\
\x20 exit 125\n"
),
};
std::fs::write(
&docker,
format!(
"#!/usr/bin/env bash\n\
if [ \"$1\" = info ]; then\n{}fi\n\
if [ \"$1\" = run ]; then\n shift\n printf '%s\\n' \"$@\" > {}\n exit 0\nfi\n\
exit 0\n",
info_branch,
shell_path(&docker_args)
),
)
.unwrap();
if let Some(uname_stdout) = uname_stdout {
std::fs::write(
&uname,
format!("#!/usr/bin/env bash\nprintf '{}\\n'\n", uname_stdout),
)
.unwrap();
}
std::fs::write(
&id,
"#!/usr/bin/env bash\n\
case \"$1\" in\n\
-u) printf '1000\\n' ;;\n\
-g) printf '1000\\n' ;;\n\
*) printf 'uid=1000 gid=1000 groups=1000\\n' ;;\n\
esac\n",
)
.unwrap();
std::fs::write(
&getenforce,
format!(
"#!/usr/bin/env bash\nprintf '{}\\n'\n",
selinux_mode.unwrap_or("Disabled")
),
)
.unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&docker, std::fs::Permissions::from_mode(0o755)).unwrap();
if uname_stdout.is_some() {
std::fs::set_permissions(&uname, std::fs::Permissions::from_mode(0o755)).unwrap();
}
std::fs::set_permissions(&id, std::fs::Permissions::from_mode(0o755)).unwrap();
std::fs::set_permissions(&getenforce, std::fs::Permissions::from_mode(0o755)).unwrap();
}
// Always prepend the fake-binary dir to PATH: `id` is shadowed
// unconditionally (see comment above), so PATH must always change, even
// when `uname_stdout` is None and only `docker`/`id` are shadowed.
let path = format!(
"{}:{}",
shell_path(tmp.path()),
std::env::var("PATH").unwrap_or_default()
);
let mut command = shell_script_command(&repo_root().join("bin/ai-memory"));
command
.args(args)
.env("PATH", path)
.env("AI_MEMORY_DOCKER", shell_path(&docker))
.env("AI_MEMORY_NO_VERSION_CHECK", "1")
.env("AI_MEMORY_DATA_VOLUME", "test-ai-memory-data")
.env("HOME", shell_path(tmp.path()))
.env_remove("AI_MEMORY_SERVER_URL")
.env_remove("CLAUDE_CONFIG_DIR");
if let Some(claude_config_dir) = claude_config_dir {
command.env("CLAUDE_CONFIG_DIR", claude_config_dir);
}
for (name, value) in forwarded_env {
command.env(name, value);
}
let output = command.output().unwrap();
assert!(
output.status.success(),
"wrapper failed: stdout={} stderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
std::fs::read_to_string(docker_args).unwrap()
}
#[cfg(unix)]
fn run_wrapper_with_fake_rootless_docker_on_fake_macos(args: &[&str]) -> String {
run_wrapper_with_fake_docker_and_uname(
args,
"[name=apparmor name=seccomp,profile=default name=rootless]",
Some("Darwin"),
)
}
#[test]
fn powershell_wrapper_lists_subscription_oauth_tokens_in_its_env_allowlist() {
let wrapper = read_repo("bin/ai-memory.ps1");
let allowlist = wrapper
.split_once("foreach ($Name in @(")
.and_then(|(_, rest)| rest.split_once(")) {"))
.map(|(allowlist, _)| allowlist)
.expect("PowerShell wrapper env allowlist");
for name in ["ANTHROPIC_OAUTH_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN"] {
assert!(
allowlist
.lines()
.any(|line| line.trim() == format!("\"{name}\",")),
"PowerShell wrapper must list {name} in the helper env allowlist"
);
}
}
#[test]
fn powershell_wrapper_trims_paths_with_single_character_separators() {
let wrapper = read_repo("bin/ai-memory.ps1");
assert_eq!(
wrapper.matches(r"TrimEnd([char[]]@('/', '\'))").count(),
2,
"PowerShell TrimEnd arguments must contain individual characters"
);
assert!(
!wrapper.contains(r"TrimEnd([char[]]@('/', '\\'))"),
"a multi-character backslash string cannot be converted to System.Char"
);
}
// The PowerShell counterpart of run_wrapper_on_fake_macos: a fake `docker.cmd`
// records the argv the wrapper built. No `uname` shadowing is needed because
// the Windows arm under test is selected by running on Windows at all.
#[cfg(windows)]
fn run_powershell_wrapper(args: &[&str]) -> String {
run_powershell_wrapper_with_server_url(args, None)
}
#[cfg(windows)]
fn run_powershell_wrapper_with_server_url(args: &[&str], server_url: Option<&str>) -> String {
let tmp = tempfile::tempdir().unwrap();
let docker_args = tmp.path().join("docker-args.txt");
let docker = tmp.path().join("docker.cmd");
std::fs::write(
&docker,
"@echo off\r\n>\"%AI_MEMORY_TEST_DOCKER_ARGS%\" echo %*\r\nexit /b 0\r\n",
)
.unwrap();
let mut command = Command::new(ai_memory_test_support::powershell_exe());
// See the execution-policy note on the OAuth-token test below.
command
.args([
"-NoLogo",
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy",
"Bypass",
"-File",
])
.arg(repo_root().join("bin/ai-memory.ps1"))
.args(args)
.env("AI_MEMORY_DOCKER", &docker)
.env("AI_MEMORY_TEST_DOCKER_ARGS", &docker_args)
.env("AI_MEMORY_DATA_VOLUME", "test-ai-memory-data")
.env_remove("AI_MEMORY_DATA_DIR");
match server_url {
Some(url) => command.env("AI_MEMORY_SERVER_URL", url),
None => command.env_remove("AI_MEMORY_SERVER_URL"),
};
let output = command.output().unwrap();
assert!(
output.status.success(),
"PowerShell wrapper failed: stdout={} stderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
std::fs::read_to_string(docker_args).unwrap()
}
#[test]
fn macos_docs_use_valid_install_commands_and_release_body_points_to_them() {
let docs = read_repo("docs/macos.md");
assert!(docs.contains("install-hooks --agent claude-code --apply"));
assert!(docs.contains("install-mcp --client claude-code --apply"));
assert!(
!docs.contains("setup-agent --agent claude-code --source ./hooks"),
"setup-agent has no --apply path; use install-hooks for native macOS docs"
);
assert!(
!docs.contains("init` configures the bearer token"),
"init writes token_pepper, not a bearer token"
);
assert!(docs.contains("Host-side agent config should use"));
assert!(docs.contains("Tagged releases publish a multi-arch manifest"));
let release = read_repo(".github/workflows/release.yml");
assert!(release.contains("follow the bundled docs/macos.md"));
}
// ---------------------------------------------------------------------------
// The generated pre-push hook and the Git environment it hands to Cargo.
//
// Git exports the repository-local environment to every hook it runs. The test
// suite builds throwaway Git repositories as fixtures, and a fixture that
// inherits GIT_DIR / GIT_WORK_TREE / GIT_INDEX_FILE runs against the checkout
// being pushed instead of its own temp directory. These tests pin the scrub.
//
// `cargo` here is a fake -- an exported Bash function that records its argv and
// the environment it was given and returns a chosen code. It is an observation
// boundary only: nothing below runs the real test suite. Git's own repository
// and config resolution is never mocked: both fixture repositories are real and
// every probe is a read-only `git` command.
//
// Unix and Windows both run these; on Windows via Git for Windows' bash.exe.
// Every give-up path goes through `skip_pre_push_tests`.
// ---------------------------------------------------------------------------
/// Every external command the installer, the generated hook, and the probes
/// below actually run. Shell builtins (`printf`, `echo`, `command`) are not
/// here; neither is `cargo`, which is an exported function, nor `rm`, which
/// only the installer's failure trap needs and these tests never reach.
#[cfg(any(unix, windows))]
const FIXTURE_TOOLS: &[&str] = &[
"awk", "bash", "cat", "chmod", "env", "git", "grep", "mktemp", "mv", "sort", "uname",
];
/// Give up on the pre-push tests, or refuse to.
///
/// A contributor whose machine lacks Git Bash or a fixture tool gets a skip,
/// matching the convention in `install_hooks`' shell-contract test. On CI that
/// same skip would turn a missing prerequisite into a green Windows job that
/// proved nothing, so there it is a failure with the concrete reason.
#[cfg(any(unix, windows))]
fn skip_pre_push_tests<T>(reason: &str) -> Option<T> {
let on_ci = ["GITHUB_ACTIONS", "CI"].iter().any(|key| {
std::env::var_os(key).is_some_and(|value| !value.is_empty() && value != "false")
});
assert!(
!on_ci,
"the pre-push hook regressions must run on CI, but {reason}"
);
eprintln!("skipping pre-push hook tests: {reason}");
None
}
/// Resolve `tool` against a `PATH`-shaped value, trying the bare name and the
/// `.exe` Windows carries.
#[cfg(any(unix, windows))]
fn resolve_in_path(path: &std::ffi::OsStr, tool: &str) -> Option<PathBuf> {
std::env::split_paths(path).find_map(|dir| {
let bare = dir.join(tool);
if bare.is_file() {
return Some(bare);
}
let exe = dir.join(format!("{tool}.exe"));
exe.is_file().then_some(exe)
})
}
/// macOS hands back `/var/folders/...` while Git reports `/private/var/...`.
/// Windows canonicalization yields a `\\?\` verbatim path that Git Bash cannot
/// use, so leave it alone there.
#[cfg(unix)]
fn sandbox_root(path: PathBuf) -> PathBuf {
std::fs::canonicalize(path).unwrap()
}
#[cfg(windows)]
fn sandbox_root(path: PathBuf) -> PathBuf {
path
}
/// The Git for Windows installation root, derived from wherever `bash.exe`
/// lives. Mirrors the discovery in `install_hooks`' shell-contract test, plus
/// the root implied by a `git.exe` already on `PATH`.
#[cfg(windows)]
fn git_for_windows_root() -> Option<PathBuf> {
let mut candidates = Vec::new();
if let Some(root) = std::env::var_os("EXEPATH") {
candidates.push(PathBuf::from(root));
}
for env_key in ["ProgramFiles", "ProgramW6432", "ProgramFiles(x86)"] {
if let Some(root) = std::env::var_os(env_key) {
candidates.push(PathBuf::from(root).join("Git"));
}
}
// `<root>/cmd/git.exe` and `<root>/mingw64/bin/git.exe` both sit under the
// installation root; walk up to it.
for dir in std::env::split_paths(&std::env::var_os("PATH").unwrap_or_default()) {
if !dir.join("git.exe").is_file() {
continue;
}
let mut up = dir.clone();
for _ in 0..2 {
if !up.pop() {
break;
}
candidates.push(up.clone());
}
}
candidates.sort();
candidates.dedup();
candidates
.into_iter()
.find(|root| {
root.join("usr").join("bin").join("bash.exe").is_file()
|| root.join("bin").join("bash.exe").is_file()
})
.or_else(|| skip_pre_push_tests("Git for Windows bash.exe was not found"))
}
/// The `PATH` the fixture hands the installer and the hook.
///
/// It must hold every tool those scripts run and must not hold `cargo-nextest`,
/// which the hook branches on. `cargo` is an exported Bash function, so it
/// never needs to be on `PATH` at all.
///
/// Unix: symlink each tool into the fixture's own `bin`. Dropping inherited
/// `PATH` directories instead would cost a contributor with `cargo-nextest`
/// next to `git` or `bash` those unrelated commands.
#[cfg(unix)]
fn fixture_path(bin: &Path) -> Option<std::ffi::OsString> {
let dirs: Vec<PathBuf> =
std::env::split_paths(&std::env::var_os("PATH").unwrap_or_default()).collect();
for tool in FIXTURE_TOOLS {
let Some(real) = dirs
.iter()
.map(|dir| dir.join(tool))
.find(|candidate| candidate.is_file())
else {
return skip_pre_push_tests(&format!("{tool} was not found on PATH"));
};
std::os::unix::fs::symlink(real, bin.join(tool)).unwrap();
}
Some(bin.as_os_str().to_owned())
}
/// Windows: NTFS has no mode bits and MSYS will not treat a plain fixture file
/// as executable, so a curated `bin` of shims is not available. Use the Git for
/// Windows tool directories instead -- they hold every tool in
/// [`FIXTURE_TOOLS`], and `cargo-nextest` (installed under `~/.cargo/bin`) is
/// excluded by construction rather than by filtering the inherited `PATH`.
#[cfg(windows)]
fn fixture_path(_bin: &Path) -> Option<std::ffi::OsString> {
let root = git_for_windows_root()?;
let dirs: Vec<PathBuf> = [
Path::new("usr").join("bin"),
Path::new("mingw64").join("bin"),
Path::new("mingw32").join("bin"),
PathBuf::from("bin"),
PathBuf::from("cmd"),
]
.iter()
.map(|relative| root.join(relative))
.filter(|dir| dir.is_dir())
.collect();
Some(std::env::join_paths(dirs).expect("Git for Windows paths must not contain ';'"))
}
#[cfg(any(unix, windows))]
fn log_field<'a>(log: &'a str, key: &str) -> &'a str {
let prefix = format!("{key}:");
log.lines()
.find_map(|line| line.strip_prefix(prefix.as_str()))
.unwrap_or_else(|| panic!("no `{key}:` line in:\n{log}"))
}
#[cfg(any(unix, windows))]
fn log_section<'a>(log: &'a str, name: &str) -> &'a str {
let begin = format!("{name}-begin\n");
let end = format!("{name}-end\n");
let start = log
.find(begin.as_str())
.unwrap_or_else(|| panic!("no `{name}-begin` in:\n{log}"))
+ begin.len();
let rest = &log[start..];
let stop = rest
.find(end.as_str())
.unwrap_or_else(|| panic!("no `{name}-end` in:\n{log}"));
&rest[..stop]
}
/// A retained sandbox for the generated pre-push hook.
///
/// `hook_repo` stands in for the checkout being pushed; `fixture_repo` for the
/// throwaway repository a test would build. Both are real Git repositories in
/// the same temp root, so even the pre-fix hook cannot reach this checkout.
#[cfg(any(unix, windows))]
struct PrePushFixture {
hook_repo: PathBuf,
fixture_repo: PathBuf,
home: PathBuf,
hook: PathBuf,
runner: PathBuf,
cargo_log: PathBuf,
caller_log: PathBuf,
global_config: PathBuf,
system_config: PathBuf,
path: std::ffi::OsString,
bash: PathBuf,
git: PathBuf,
/// `Some` under Git Bash, where scripts need MSYS paths. `None` on Unix.
cygpath: Option<PathBuf>,
hook_toplevel: String,
fixture_toplevel: String,
}
#[cfg(any(unix, windows))]
impl PrePushFixture {
fn new() -> Option<Self> {
// `keep()`: the sandbox is the evidence behind a failure, so it outlives
// the test rather than being deleted on drop.
let root = sandbox_root(tempfile::tempdir().unwrap().keep());
assert!(
!root.starts_with(repo_root()),
"the sandbox must live outside this checkout, got {}",
root.display()
);
// Every fixture path carries an apostrophe and a space, so a path
// interpolated into a shell literal anywhere below would break.
let work = root.join("it's a sandbox");
let home = work.join("home");
let bin = work.join("bin");
let hook_repo = work.join("hook repo");
let fixture_repo = work.join("fixture repo");
for dir in [&home, &bin, &hook_repo, &fixture_repo] {
std::fs::create_dir_all(dir).unwrap();
}
std::fs::create_dir_all(home.join(".config")).unwrap();
// Synthetic machine config. The caller must keep seeing these; Cargo
// must not.
let global_config = work.join("global.gitconfig");
let system_config = work.join("system.gitconfig");
std::fs::write(&global_config, "[fixture]\n\tglobal = from-global-config\n").unwrap();
std::fs::write(&system_config, "[fixture]\n\tsystem = from-system-config\n").unwrap();
let path = fixture_path(&bin)?;
for tool in FIXTURE_TOOLS {
if resolve_in_path(&path, tool).is_none() {
return skip_pre_push_tests(&format!(
"{tool} is missing from the fixture PATH ({})",
path.to_string_lossy()
));
}
}
if let Some(found) = resolve_in_path(&path, "cargo-nextest") {
return skip_pre_push_tests(&format!(
"cargo-nextest is reachable at {}, so the fallback branch is not deterministic",
found.display()
));
}
let cygpath = resolve_in_path(&path, "cygpath");
if cfg!(windows) && cygpath.is_none() {
return skip_pre_push_tests("cygpath is missing from the Git for Windows PATH");
}
let mut fixture = Self {
bash: resolve_in_path(&path, "bash").expect("checked above"),
git: resolve_in_path(&path, "git").expect("checked above"),
hook: hook_repo.join(".git").join("hooks").join("pre-push"),
runner: work.join("runner.sh"),
cargo_log: work.join("cargo.log"),
caller_log: work.join("caller.log"),
cygpath,
path,
hook_repo,
fixture_repo,
home,
global_config,
system_config,
hook_toplevel: String::new(),
fixture_toplevel: String::new(),
};
fixture.git(&fixture.hook_repo, &["init", "-q"]);
fixture.git(&fixture.fixture_repo, &["init", "-q"]);
// Compare Git's own answers rather than Rust path strings: Git Bash
// reports `C:/...` where Rust reports `C:\...`.
fixture.hook_toplevel = fixture.git(&fixture.hook_repo, &["rev-parse", "--show-toplevel"]);
fixture.fixture_toplevel =
fixture.git(&fixture.fixture_repo, &["rev-parse", "--show-toplevel"]);
Some(fixture)
}
/// Drop the ambient Git environment and point HOME and the global/system
/// config at the sandbox.
fn isolate(&self, command: &mut Command) {
for (key, _) in std::env::vars_os() {
if key.to_string_lossy().starts_with("GIT_") {
command.env_remove(&key);
}
}
command
.env("PATH", &self.path)
.env("HOME", &self.home)
.env("USERPROFILE", &self.home)
.env("XDG_CONFIG_HOME", self.home.join(".config"))
.env("GIT_CONFIG_GLOBAL", &self.global_config)
.env("GIT_CONFIG_SYSTEM", &self.system_config);
}
/// The paths the generated scripts read. They go through the environment so
/// no path is ever interpolated into a shell literal.
fn shell_env(&self, command: &mut Command) {
command
.env("AI_MEMORY_FIXTURE_REPO", self.shell_arg(&self.fixture_repo))
.env(
"AI_MEMORY_FIXTURE_CARGO_LOG",
self.shell_arg(&self.cargo_log),
)
.env(
"AI_MEMORY_FIXTURE_CALLER_LOG",
self.shell_arg(&self.caller_log),
)
.env("AI_MEMORY_FIXTURE_HOOK", self.shell_arg(&self.hook));
}
/// A path as the fixture's shell sees it: unchanged on Unix, MSYS-converted
/// by `cygpath` under Git Bash.
fn shell_arg(&self, path: &Path) -> String {
let Some(cygpath) = &self.cygpath else {
return path.display().to_string();
};
let output = Command::new(cygpath).arg("-u").arg(path).output().unwrap();
assert!(
output.status.success(),
"cygpath -u {} failed: {}",
path.display(),
String::from_utf8_lossy(&output.stderr)
);
String::from_utf8(output.stdout).unwrap().trim().to_string()
}
fn git(&self, dir: &Path, args: &[&str]) -> String {
let mut command = Command::new(&self.git);
self.isolate(&mut command);
let output = command.args(args).current_dir(dir).output().unwrap();
assert!(
output.status.success(),
"git {args:?} in {} failed: {}",
dir.display(),
String::from_utf8_lossy(&output.stderr)
);
String::from_utf8(output.stdout).unwrap().trim().to_string()
}
/// A read-only probe block, reused by the fake Cargo and by the user hook.
fn probe(&self, name: &str) -> String {
let log = if name == "cargo" {
"AI_MEMORY_FIXTURE_CARGO_LOG"
} else {
"AI_MEMORY_FIXTURE_CALLER_LOG"
};
format!(
r#"{{
printf '{name}-begin\n'
env | grep '^GIT_' | LC_ALL=C sort
printf '{name}-end\n'
printf '{name}-ifs:[%s]\n' "$IFS"
printf '{name}-toplevel:%s\n' "$(git -C "$AI_MEMORY_FIXTURE_REPO" rev-parse --show-toplevel 2>&1)"
printf '{name}-injected:%s\n' "$(git -C "$AI_MEMORY_FIXTURE_REPO" config --get fixture.injected 2>&1)"
printf '{name}-counted:%s\n' "$(git -C "$AI_MEMORY_FIXTURE_REPO" config --get fixture.counted 2>&1)"
printf '{name}-global:%s\n' "$(git -C "$AI_MEMORY_FIXTURE_REPO" config --get fixture.global 2>&1)"
printf '{name}-system:%s\n' "$(git -C "$AI_MEMORY_FIXTURE_REPO" config --get fixture.system 2>&1)"
}} >> "${log}" 2>&1
"#
)
}
/// The runner that stands in for Cargo and then execs the installed hook.
///
/// Both fakes are exported Bash functions: a function needs no execute bit,
/// which NTFS cannot give a fixture file under Git Bash, and it lets the
/// hook's `command -v cargo-nextest` probe answer what the test asked for.
fn write_runner(&self, exit_code: i32, with_nextest: bool) {
let mut runner = format!(
"#!/usr/bin/env bash\n\
cargo() {{\n\
\x20 printf 'cargo-argv:%s\\n' \"$*\" >> \"$AI_MEMORY_FIXTURE_CARGO_LOG\"\n\
{probe}\
\x20 return {exit_code}\n\
}}\n\
export -f cargo\n",
probe = self.probe("cargo"),
);
if with_nextest {
runner.push_str("cargo-nextest() { return 0; }\nexport -f cargo-nextest\n");
}
runner.push_str("exec bash \"$AI_MEMORY_FIXTURE_HOOK\"\n");
std::fs::write(&self.runner, runner).unwrap();
}
/// Seed a user hook whose own content precedes the managed block.
fn write_user_hook(&self, prelude: &str) {
std::fs::write(
&self.hook,
format!(
"#!/usr/bin/env bash\n# A user hook that predates the managed block.\n{prelude}{}",
self.probe("before"),
),
)
.unwrap();
}
/// Append the user's trailing content after the managed block's end marker.
fn append_after_managed_block(&self) {
let installed = std::fs::read_to_string(&self.hook).unwrap();
assert!(
installed
.trim_end()
.ends_with("# <<< ai-memory pre-push <<<"),
"the managed block should close the installed hook:\n{installed}"
);
std::fs::write(&self.hook, format!("{installed}{}", self.probe("after"))).unwrap();
}
fn install_hook(&self) {
let mut command = Command::new(&self.bash);
command.arg(self.shell_arg(&repo_root().join("scripts/install-git-hooks.sh")));
self.isolate(&mut command);
let output = command.current_dir(&self.hook_repo).output().unwrap();
assert!(
output.status.success(),
"installer failed: stdout={} stderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
/// Run the installed hook with exactly the repository-local environment Git
/// exports to a pre-push hook. Every injected path is inside the sandbox.
fn run_hook(&self) -> std::process::Output {
let mut command = Command::new(&self.bash);
command.arg(self.shell_arg(&self.runner));
self.isolate(&mut command);
self.shell_env(&mut command);
command
.current_dir(&self.hook_repo)
.env("GIT_DIR", self.hook_repo.join(".git"))
.env("GIT_COMMON_DIR", self.hook_repo.join(".git"))
.env("GIT_WORK_TREE", &self.hook_repo)
.env("GIT_INDEX_FILE", self.hook_repo.join(".git").join("index"))
.env("GIT_PREFIX", "")
.env(
"GIT_CONFIG_PARAMETERS",
"'fixture.injected=from-parameters'",
)
.env("GIT_CONFIG_COUNT", "1")
.env("GIT_CONFIG_KEY_0", "fixture.counted")
.env("GIT_CONFIG_VALUE_0", "from-count");
command.output().unwrap()
}
fn cargo_log(&self) -> String {
std::fs::read_to_string(&self.cargo_log).unwrap_or_default()
}
fn caller_log(&self) -> String {
std::fs::read_to_string(&self.caller_log).unwrap_or_default()
}
}
#[test]
#[cfg(any(unix, windows))]
fn pre_push_hook_scrubs_the_repository_git_environment_before_cargo() {
let Some(fixture) = PrePushFixture::new() else {
return;
};
fixture.write_runner(0, false);
fixture.install_hook();
let output = fixture.run_hook();
assert!(
output.status.success(),
"hook failed: stdout={} stderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let log = fixture.cargo_log();
let git_env = log_section(&log, "cargo");
for leaked in [
"GIT_DIR=",
"GIT_COMMON_DIR=",
"GIT_WORK_TREE=",
"GIT_INDEX_FILE=",
"GIT_CONFIG_PARAMETERS=",
"GIT_CONFIG_COUNT=",
"GIT_PREFIX=",
] {
assert!(
!git_env.contains(leaked),
"{leaked} reached cargo; a fixture repo would act on the pushed checkout:\n{git_env}"
);
}
for expected in [
"GIT_CONFIG_NOSYSTEM=1",
"GIT_CONFIG_GLOBAL=/dev/null",
"GIT_CONFIG_SYSTEM=/dev/null",
] {
assert!(
git_env.contains(expected),
"cargo should run with {expected}:\n{git_env}"
);
}
// The decisive check: a child `git` aimed at the fixture repository must
// land there, not in the repository whose hook is running.
assert_eq!(
log_field(&log, "cargo-toplevel"),
fixture.fixture_toplevel,
"child git resolved the wrong repository:\n{log}"
);
for (key, source) in [
("cargo-injected", "GIT_CONFIG_PARAMETERS"),
("cargo-counted", "GIT_CONFIG_COUNT"),
("cargo-global", "the global config"),
("cargo-system", "the system config"),
] {
assert_eq!(
log_field(&log, key),
"",
"{source} still reached the fixture repository:\n{log}"
);
}
}
#[test]
#[cfg(any(unix, windows))]
fn pre_push_hook_leaves_the_user_hook_environment_intact_around_the_block() {
let Some(fixture) = PrePushFixture::new() else {
return;
};
fixture.write_runner(0, false);
fixture.write_user_hook("");
fixture.install_hook();
fixture.append_after_managed_block();
let output = fixture.run_hook();
assert!(
output.status.success(),
"hook failed: stdout={} stderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let log = fixture.caller_log();
for stage in ["before", "after"] {
let git_env = log_section(&log, stage);
for kept in ["GIT_DIR=", "GIT_INDEX_FILE=", "GIT_CONFIG_COUNT=1"] {
assert!(
git_env.contains(kept),
"the scrub escaped the managed block and took {kept} from the {stage} content:\n{git_env}"
);
}
// The scrub's own settings must not leak outward either: the caller
// keeps its synthetic global/system config and never sees NOSYSTEM.
assert!(
!git_env.contains("GIT_CONFIG_GLOBAL=/dev/null")
&& !git_env.contains("GIT_CONFIG_SYSTEM=/dev/null"),
"the {stage} content must keep the caller's own config paths:\n{git_env}"
);
assert!(
!git_env.contains("GIT_CONFIG_NOSYSTEM"),
"the {stage} content must not inherit the scrub's GIT_CONFIG_NOSYSTEM:\n{git_env}"
);
// The caller's Git still resolves through the inherited GIT_DIR, which
// points at the hook's repository rather than the fixture repository
// the command names.
assert_eq!(
log_field(&log, &format!("{stage}-toplevel")),
fixture.hook_toplevel,
"the {stage} content lost the repository Git gave it:\n{log}"
);
for (key, expected, source) in [
("global", "from-global-config", "the global config"),
("system", "from-system-config", "the system config"),
("injected", "from-parameters", "GIT_CONFIG_PARAMETERS"),
("counted", "from-count", "GIT_CONFIG_COUNT"),
] {
assert_eq!(
log_field(&log, &format!("{stage}-{key}")),
expected,
"the {stage} content lost {source}:\n{log}"
);
}
}
}
/// Reinstalling must keep the user's own content and leave exactly one managed
/// block.
///
/// It asserts nothing about where that content ends up relative to the block:
/// the installer strips the old block and appends the new one at the end, so a
/// body that used to follow the block now precedes it. That reordering predates
/// this change and is deliberately left alone here.
#[test]
#[cfg(any(unix, windows))]
fn pre_push_hook_reinstall_keeps_user_content_and_one_managed_block() {
let Some(fixture) = PrePushFixture::new() else {
return;
};
fixture.write_runner(0, false);
fixture.write_user_hook("");
fixture.install_hook();
fixture.append_after_managed_block();
fixture.install_hook();
let installed = std::fs::read_to_string(&fixture.hook).unwrap();
assert_eq!(
installed.matches("# >>> ai-memory pre-push >>>").count(),
1,
"reinstalling duplicated the managed block:\n{installed}"
);
assert_eq!(
installed.matches("printf 'before-begin\\n'").count(),
1,
"reinstalling lost or duplicated the user's leading content:\n{installed}"
);
assert_eq!(
installed.matches("printf 'after-begin\\n'").count(),
1,
"reinstalling lost or duplicated the user's trailing content:\n{installed}"
);
let output = fixture.run_hook();
assert!(
output.status.success(),
"reinstalled hook failed: stdout={} stderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
assert_eq!(
log_field(&fixture.cargo_log(), "cargo-toplevel"),
fixture.fixture_toplevel,
"the scrub did not survive a reinstall"
);
}
/// A user hook that narrowed `IFS` before the managed block used to break the
/// split of `git rev-parse --local-env-vars`, leaving the whole newline-joined
/// list as one word.
#[test]
#[cfg(any(unix, windows))]
fn pre_push_hook_splits_the_local_env_var_list_under_a_narrowed_ifs() {
let Some(fixture) = PrePushFixture::new() else {
return;
};
fixture.write_runner(0, false);
fixture.write_user_hook("IFS=:\n");
fixture.install_hook();
fixture.append_after_managed_block();
let output = fixture.run_hook();
assert!(
output.status.success(),
"the hook did not survive a narrowed IFS: stdout={} stderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
assert_eq!(
log_field(&fixture.cargo_log(), "cargo-toplevel"),
fixture.fixture_toplevel,
"the scrub did not survive a narrowed IFS"
);
let caller = fixture.caller_log();
for stage in ["before", "after"] {
assert_eq!(
log_field(&caller, &format!("{stage}-ifs")),
"[:]",
"the managed block changed the user hook's IFS:\n{caller}"
);
}
}
#[test]
#[cfg(any(unix, windows))]
fn pre_push_hook_keeps_the_full_tier_and_propagates_a_cargo_failure() {
for (with_nextest, expected_argv, expected_notice) in [
(
true,
"nextest run --workspace -P full",
"pre-push: cargo nextest run --workspace -P full",
),
(
false,
"test --workspace --all-targets",
"pre-push: cargo test --workspace --all-targets (nextest not installed)",
),
] {
let Some(fixture) = PrePushFixture::new() else {
return;
};
fixture.write_runner(0, with_nextest);
fixture.install_hook();
let output = fixture.run_hook();
assert!(
output.status.success(),
"hook failed with {expected_argv:?}: stdout={} stderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
assert_eq!(
log_field(&fixture.cargo_log(), "cargo-argv"),
expected_argv,
"wrong cargo invocation for nextest={with_nextest}"
);
assert!(
String::from_utf8_lossy(&output.stdout).contains(expected_notice),
"the hook stopped announcing what it runs: {}",
String::from_utf8_lossy(&output.stdout)
);
}
// A failing test run must still stop the push.
let Some(fixture) = PrePushFixture::new() else {
return;
};
fixture.write_runner(3, true);
fixture.install_hook();
let output = fixture.run_hook();
assert_eq!(
output.status.code(),
Some(3),
"the subshell swallowed cargo's exit status: stdout={} stderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
/// The slow tier. These drive the real wrapper scripts, a fake `uname`, and
/// fake container engines, 10-20s each, and cannot break from an ordinary Rust
/// edit. `.config/nextest.toml` skips every `slow::` module in the everyday
/// profile; `-P full`, the pre-push hook, and CI run them.
mod slow {
#![allow(unused_imports)]
use super::*;
#[cfg(unix)]
#[test]
fn macos_wrapper_routes_urls_by_real_subcommand() {
for subcommand in ["install-mcp", "install-hooks", "setup-agent"] {
let args = run_wrapper_on_fake_macos(&[subcommand]);
assert!(
!args.contains("AI_MEMORY_SERVER_URL=http://host.docker.internal:49374"),
"{subcommand} renders host-side config and must keep loopback defaults; got {args}"
);
}
let args = run_wrapper_on_fake_macos(&["status"]);
assert!(
args.contains("AI_MEMORY_SERVER_URL=http://host.docker.internal:49374"),
"thin-client commands must reach the host server through Docker Desktop; got {args}"
);
let args = run_wrapper_on_fake_macos(&["search", "install-hooks"]);
assert!(
args.contains("AI_MEMORY_SERVER_URL=http://host.docker.internal:49374"),
"only the actual subcommand should control URL routing; got {args}"
);
let args = run_wrapper_on_fake_macos(&["--config", "/tmp/config.toml", "install-hooks"]);
assert!(
!args.contains("AI_MEMORY_SERVER_URL=http://host.docker.internal:49374"),
"global options before install-hooks must not hide the real subcommand; got {args}"
);
}
#[cfg(unix)]
#[test]
fn wrapper_keeps_stdin_attached_when_it_is_a_pipe() {
let args = run_wrapper_with_piped_stdin(
&["write-page", "--path", "notes/x.md", "--body", "-"],
"# body that must survive the container boundary\n",
);
let flags: Vec<&str> = args.lines().collect();
// Without `-i` docker gives the container a closed stdin, so `--body -`
// reads an empty string and the page is persisted with frontmatter only —
// silently, because the CLI still reports a successful write.
assert!(
flags.contains(&"-i"),
"piped stdin must stay attached for `--body -`; got {args}"
);
// A pipe is not a terminal: asking for a TTY here makes docker fail with
// "the input device is not a TTY".
assert!(
!flags.contains(&"-t") && !flags.contains(&"-it"),
"no TTY may be requested when stdin is a pipe; got {args}"
);
assert!(
flags
.iter()
.any(|arg| arg.starts_with("AI_MEMORY_SCOPE_CWD=/scope")),
"an outside-home checkout must expose its bounded marker path; got {args}"
);
assert!(
flags.iter().any(|arg| arg.ends_with(":/scope:ro")),
"the marker scope mount must be read-only; got {args}"
);
}
#[cfg(unix)]
#[test]
fn wrapper_self_upgrade_rejects_a_checksum_mismatch() {
let tmp = tempfile::tempdir().unwrap();
let bin_dir = tmp.path().join("bin");
std::fs::create_dir_all(&bin_dir).unwrap();
let wrapper = bin_dir.join("ai-memory");
let original = read_repo("bin/ai-memory");
std::fs::write(&wrapper, &original).unwrap();
let payload = tmp.path().join("hostile-wrapper");
std::fs::write(
&payload,
"#!/usr/bin/env bash\nprintf 'hostile payload executed\\n' >&2\nexit 91\n",
)
.unwrap();
let curl = bin_dir.join("curl");
std::fs::write(
&curl,
"#!/usr/bin/env bash\n\
set -euo pipefail\n\
url=''\n\
out=''\n\
while [ \"$#\" -gt 0 ]; do\n\
case \"$1\" in\n\
-o) out=\"$2\"; shift 2 ;;\n\
-*) shift ;;\n\
*) url=\"$1\"; shift ;;\n\
esac\n\
done\n\
case \"$url\" in\n\
*.sha256) printf '%064d ai-memory-wrapper\\n' 0 > \"$out\" ;;\n\
*) cp \"$FAKE_WRAPPER_PAYLOAD\" \"$out\" ;;\n\
esac\n",
)
.unwrap();
let docker = bin_dir.join("docker");
std::fs::write(&docker, "#!/usr/bin/env bash\nexit 0\n").unwrap();
{
use std::os::unix::fs::PermissionsExt as _;
for path in [&wrapper, &payload, &curl, &docker] {
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap();
}
}
let path = format!(
"{}:{}",
shell_path(&bin_dir),
std::env::var("PATH").unwrap_or_default()
);
let output = freshly_written_shell_script_command(&wrapper)
.arg("upgrade")
.env("PATH", path)
.env("HOME", tmp.path())
.env("AI_MEMORY_DOCKER", &docker)
.env(
"AI_MEMORY_WRAPPER_URL",
"https://example.invalid/ai-memory-wrapper",
)
.env("FAKE_WRAPPER_PAYLOAD", &payload)
.output()
.unwrap();
assert!(
output.status.success(),
"upgrade failed: stdout={} stderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("wrapper checksum mismatch; refusing update"));
assert!(!String::from_utf8_lossy(&output.stderr).contains("hostile payload executed"));
assert_eq!(std::fs::read_to_string(&wrapper).unwrap(), original);
}
#[cfg(unix)]
#[test]
fn wrapper_self_upgrade_installs_and_runs_a_verified_payload() {
let tmp = tempfile::tempdir().unwrap();
let bin_dir = tmp.path().join("bin");
std::fs::create_dir_all(&bin_dir).unwrap();
let wrapper = bin_dir.join("ai-memory");
std::fs::write(&wrapper, read_repo("bin/ai-memory")).unwrap();
let payload = tmp.path().join("verified-wrapper");
let payload_body = "#!/usr/bin/env bash\nprintf 'verified wrapper executed\\n'\n";
std::fs::write(&payload, payload_body).unwrap();
let curl = bin_dir.join("curl");
std::fs::write(
&curl,
"#!/usr/bin/env bash\n\
set -euo pipefail\n\
url=''\n\
out=''\n\
while [ \"$#\" -gt 0 ]; do\n\
case \"$1\" in\n\
-o) out=\"$2\"; shift 2 ;;\n\
-*) shift ;;\n\
*) url=\"$1\"; shift ;;\n\
esac\n\
done\n\
case \"$url\" in\n\
*.sha256) printf '%s ai-memory-wrapper\\n' \"$FAKE_WRAPPER_CHECKSUM\" > \"$out\" ;;\n\
*) cp \"$FAKE_WRAPPER_PAYLOAD\" \"$out\" ;;\n\
esac\n",
)
.unwrap();
{
use std::os::unix::fs::PermissionsExt as _;
for path in [&wrapper, &payload, &curl] {
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap();
}
}
let path = format!(
"{}:{}",
shell_path(&bin_dir),
std::env::var("PATH").unwrap_or_default()
);
let output = freshly_written_shell_script_command(&wrapper)
.arg("upgrade")
.env("PATH", path)
.env("HOME", tmp.path())
.env(
"AI_MEMORY_WRAPPER_URL",
"https://example.invalid/ai-memory-wrapper",
)
.env("FAKE_WRAPPER_PAYLOAD", &payload)
.env("FAKE_WRAPPER_CHECKSUM", sha256_file(&payload))
.output()
.unwrap();
assert!(
output.status.success(),
"upgrade failed: stdout={} stderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
assert!(String::from_utf8_lossy(&output.stdout).contains("verified wrapper executed"));
assert_eq!(std::fs::read_to_string(&wrapper).unwrap(), payload_body);
}
#[cfg(unix)]
#[test]
fn hook_installer_rejects_a_checksum_mismatch_before_writing_scripts() {
let tmp = tempfile::tempdir().unwrap();
let bin_dir = tmp.path().join("bin");
std::fs::create_dir_all(&bin_dir).unwrap();
let curl = bin_dir.join("curl");
std::fs::write(
&curl,
"#!/usr/bin/env bash\n\
set -euo pipefail\n\
url=''\n\
out=''\n\
while [ \"$#\" -gt 0 ]; do\n\
case \"$1\" in\n\
-o) out=\"$2\"; shift 2 ;;\n\
-*) shift ;;\n\
*) url=\"$1\"; shift ;;\n\
esac\n\
done\n\
case \"$url\" in\n\
*.sha256) printf '%064d ai-memory-hooks.tar.gz\\n' 0 > \"$out\" ;;\n\
*) printf 'not the expected archive' > \"$out\" ;;\n\
esac\n",
)
.unwrap();
{
use std::os::unix::fs::PermissionsExt as _;
std::fs::set_permissions(&curl, std::fs::Permissions::from_mode(0o755)).unwrap();
}
let path = format!(
"{}:{}",
shell_path(&bin_dir),
std::env::var("PATH").unwrap_or_default()
);
let destination = tmp.path().join("hooks");
let output = shell_script_command(&repo_root().join("scripts/install-hooks.sh"))
.args(["--agent", "claude-code", "--to"])
.arg(&destination)
.env("PATH", path)
.env("HOME", tmp.path())
.output()
.unwrap();
assert!(
!output.status.success(),
"checksum mismatch must fail closed"
);
assert!(
String::from_utf8_lossy(&output.stderr)
.contains("hook bundle checksum mismatch; refusing installation")
);
let agent_dir = destination.join("claude-code");
assert!(
!agent_dir.exists() || std::fs::read_dir(agent_dir).unwrap().next().is_none(),
"no hook script may be written before archive verification"
);
}
#[cfg(unix)]
#[test]
fn hook_installer_writes_only_expected_files_from_a_verified_archive() {
const HOOKS: &[&str] = &[
"post-tool-use",
"pre-compact",
"pre-tool-use",
"session-end",
"session-start",
"stop",
"user-prompt-submit",
];
let names = installed_hook_names("claude-code", "claude-code", HOOKS);
let expected = HOOKS
.iter()
.map(|hook| format!("{hook}.sh"))
.collect::<Vec<_>>();
assert_eq!(names, expected);
}
#[cfg(unix)]
#[test]
fn hook_installer_writes_only_command_code_stable_events() {
const HOOKS: &[&str] = &["post-tool-use", "pre-tool-use", "session-start", "stop"];
let names = installed_hook_names("cmdc", "command-code", HOOKS);
let expected = HOOKS
.iter()
.map(|hook| format!("{hook}.sh"))
.collect::<Vec<_>>();
assert_eq!(names, expected);
}
#[cfg(unix)]
#[test]
fn managed_host_commands_use_native_path_and_remote_server_without_docker() {
let tmp = tempfile::tempdir().unwrap();
let native = tmp.path().join("native-ai-memory");
let docker = tmp.path().join("docker");
let record = tmp.path().join("native-record.txt");
let docker_record = tmp.path().join("docker-record.txt");
std::fs::write(
&native,
format!(
"#!/usr/bin/env bash\n\
printf 'server=%s\\nauth=%s\\npath=%s\\n' \"$AI_MEMORY_SERVER_URL\" \"$AI_MEMORY_AUTH_TOKEN\" \"$PATH\" > {}\n\
printf 'arg=%s\\n' \"$@\" >> {}\n",
shell_path(&record),
shell_path(&record)
),
)
.unwrap();
std::fs::write(
&docker,
format!(
"#!/usr/bin/env bash\nprintf '%s\\n' \"$@\" > {}\nexit 99\n",
shell_path(&docker_record)
),
)
.unwrap();
{
use std::os::unix::fs::PermissionsExt as _;
std::fs::set_permissions(&native, std::fs::Permissions::from_mode(0o755)).unwrap();
std::fs::set_permissions(&docker, std::fs::Permissions::from_mode(0o755)).unwrap();
}
let host_path = format!(
"{}:{}",
shell_path(tmp.path()),
std::env::var("PATH").unwrap_or_default()
);
let commands: &[&[&str]] = &[
&["run", "codex", "--yolo", "resume"],
&["show", "--json", "--no-scan"],
&["continue", "--workspace", "work", "--yolo"],
&["resume", "--workspace", "work", "--limit", "5"],
&["workstreams", "--limit", "5", "--json"],
&["rename-workstream", "--from", "old", "--to", "new"],
];
for args in commands {
let output = shell_script_command(&repo_root().join("bin/ai-memory"))
.args(args.iter().copied())
.env("AI_MEMORY_NATIVE_BIN", &native)
.env("AI_MEMORY_DOCKER", &docker)
.env("AI_MEMORY_SERVER_URL", "http://192.168.0.90:49374")
.env("AI_MEMORY_AUTH_TOKEN", "remote-test-token")
.env("PATH", &host_path)
.output()
.unwrap();
assert!(
output.status.success(),
"wrapper failed for {args:?}: {}",
String::from_utf8_lossy(&output.stderr)
);
let mut expected = format!(
"server=http://192.168.0.90:49374\n\
auth=remote-test-token\n\
path={host_path}\n"
);
for arg in *args {
expected.push_str(&format!("arg={arg}\n"));
}
assert_eq!(std::fs::read_to_string(&record).unwrap(), expected);
}
assert!(
!docker_record.exists(),
"managed host command entered Docker"
);
}
#[cfg(unix)]
#[test]
fn wrapper_upgrade_does_not_claim_an_updated_remote_server_is_stale() {
let tmp = tempfile::tempdir().unwrap();
let docker = tmp.path().join("docker");
std::fs::write(
&docker,
"#!/usr/bin/env bash\n\
case \"$1\" in\n\
pull | ps) exit 0 ;;\n\
*) exit 1 ;;\n\
esac\n",
)
.unwrap();
{
use std::os::unix::fs::PermissionsExt as _;
std::fs::set_permissions(&docker, std::fs::Permissions::from_mode(0o755)).unwrap();
}
let output = shell_script_command(&repo_root().join("bin/ai-memory"))
.arg("upgrade")
.env("AI_MEMORY_DOCKER", &docker)
.env("AI_MEMORY_SKIP_SELF_UPGRADE", "1")
.env("AI_MEMORY_SERVER_URL", "http://192.168.0.90:49374")
.env("HOME", tmp.path())
.output()
.unwrap();
assert!(
output.status.success(),
"wrapper failed: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("does not\n inspect or redeploy the remote server"));
assert!(stdout.contains("If that host is not already current"));
assert!(!stdout.contains("remote server still\n runs the previous version"));
}
#[cfg(unix)]
#[test]
fn docker_wrapper_completions_tolerate_an_early_reader_close() {
let tmp = tempfile::tempdir().unwrap();
let docker = tmp.path().join("docker");
std::fs::write(
&docker,
"#!/usr/bin/env bash\n\
if [ \"$1\" = info ]; then\n\
printf '[name=seccomp,profile=default]\\n'\n\
exit 0\n\
fi\n\
if [ \"$1\" = run ]; then\n\
i=0\n\
while [ \"$i\" -lt 20000 ]; do\n\
printf 'complete -c ai-memory -n condition-%s\\n' \"$i\"\n\
i=$((i + 1))\n\
done\n\
exit 0\n\
fi\n\
exit 1\n",
)
.unwrap();
{
use std::os::unix::fs::PermissionsExt as _;
std::fs::set_permissions(&docker, std::fs::Permissions::from_mode(0o755)).unwrap();
}
let mut child = shell_script_command(&repo_root().join("bin/ai-memory"))
.args(["completions", "fish"])
.env("AI_MEMORY_DOCKER", &docker)
.env("AI_MEMORY_NO_TTY", "1")
.env("AI_MEMORY_NO_VERSION_CHECK", "1")
.env("AI_MEMORY_DATA_VOLUME", "test-ai-memory-data")
.env("HOME", tmp.path())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
let mut stdout = std::io::BufReader::new(child.stdout.take().unwrap());
let mut first_line = String::new();
stdout.read_line(&mut first_line).unwrap();
drop(stdout);
let output = child.wait_with_output().unwrap();
assert_eq!(first_line, "complete -c ai-memory -n condition-0\n");
assert!(
output.status.success(),
"early close should stay quiet and successful: {}",
String::from_utf8_lossy(&output.stderr)
);
assert!(
!String::from_utf8_lossy(&output.stderr).contains("broken pipe"),
"wrapper leaked Docker's broken-pipe diagnostic: {}",
String::from_utf8_lossy(&output.stderr)
);
}
#[cfg(unix)]
#[test]
fn docker_wrapper_completions_preserve_helper_failure_without_partial_output() {
let tmp = tempfile::tempdir().unwrap();
let docker = tmp.path().join("docker");
std::fs::write(
&docker,
"#!/usr/bin/env bash\n\
if [ \"$1\" = info ]; then\n\
printf '[name=seccomp,profile=default]\\n'\n\
exit 0\n\
fi\n\
if [ \"$1\" = run ]; then\n\
printf 'partial completion output\\n'\n\
printf 'helper failed\\n' >&2\n\
exit 42\n\
fi\n\
exit 1\n",
)
.unwrap();
{
use std::os::unix::fs::PermissionsExt as _;
std::fs::set_permissions(&docker, std::fs::Permissions::from_mode(0o755)).unwrap();
}
let output = shell_script_command(&repo_root().join("bin/ai-memory"))
.args(["completions", "fish"])
.env("AI_MEMORY_DOCKER", &docker)
.env("AI_MEMORY_NO_TTY", "1")
.env("AI_MEMORY_NO_VERSION_CHECK", "1")
.env("AI_MEMORY_DATA_VOLUME", "test-ai-memory-data")
.env("HOME", tmp.path())
.output()
.unwrap();
assert_eq!(output.status.code(), Some(42));
assert!(
output.stdout.is_empty(),
"failed helper leaked partial completions: {}",
String::from_utf8_lossy(&output.stdout)
);
assert_eq!(String::from_utf8_lossy(&output.stderr), "helper failed\n");
}
#[cfg(unix)]
#[test]
fn wrapper_forwards_claude_config_dir_to_helper_container() {
let args = run_wrapper_with_fake_docker_and_claude_config(
&["install-hooks", "--agent", "claude-code", "--apply"],
"[name=seccomp,profile=default]",
"/home/alice/.config/claude",
);
assert!(
args.contains("-e\nCLAUDE_CONFIG_DIR"),
"wrapper must forward Claude's config root; got {args}"
);
}
#[cfg(unix)]
#[test]
fn rootless_docker_uses_root_uid_only_for_host_config_commands() {
let rootless_info = "[name=apparmor name=seccomp,profile=default name=rootless]";
for subcommand in [
"install-mcp",
"install-hooks",
"setup-agent",
"install-instructions",
"install-skills",
// uninstall edits the same host agent-config files; backup writes
// its tarball to a host path, and restore reads one — same bind
// mounts, same UID rule.
"uninstall",
"backup",
"restore",
] {
let args = run_wrapper_with_fake_docker(&[subcommand], rootless_info);
assert!(
args.contains("-u\n0:0"),
"{subcommand} writes host bind-mounted files and must run as root \
under rootless Docker so the write lands as the real host user \
(rootlesskit only maps container UID 0 back to it); got {args}"
);
}
let args = run_wrapper_with_fake_docker(&["status"], rootless_info);
assert!(
!args.contains("-u\n0:0"),
"thin-client commands only touch the /data named volume, which isn't \
host-visible, so they must keep the host-UID mapping; got {args}"
);
}
#[cfg(unix)]
#[test]
fn fake_macos_rootless_docker_keeps_root_uid_for_host_config_commands() {
let args = run_wrapper_with_fake_rootless_docker_on_fake_macos(&["install-mcp"]);
assert!(
args.contains("-u\n0:0"),
"macOS rootless Docker still needs uid 0 for host config writes; got {args}"
);
let args = run_wrapper_with_fake_rootless_docker_on_fake_macos(&["status"]);
assert!(
!args.contains("-u\n0:0"),
"macOS thin-client commands should keep Docker Desktop's default uid; got {args}"
);
}
#[cfg(unix)]
#[test]
fn fake_macos_rootful_docker_keeps_default_uid_for_host_config_commands() {
let args = run_wrapper_with_fake_docker_and_uname(
&["install-mcp"],
"[name=seccomp,profile=default]",
Some("Darwin"),
);
assert!(
!args.contains("-u\n0:0") && !args.contains("-u\n"),
"macOS rootful Docker should keep Docker Desktop's default uid; got {args}"
);
}
#[cfg(unix)]
#[test]
fn rootful_docker_keeps_host_uid_for_host_config_commands() {
let rootful_info = "[name=seccomp,profile=default]";
let args = run_wrapper_with_fake_docker(&["install-hooks"], rootful_info);
assert!(
!args.contains("-u\n0:0"),
"rootful Docker must not switch to root UID — that would write \
~/.local/share/ai-memory/hooks owned by root instead of the invoking \
user; got {args}"
);
}
#[cfg(unix)]
#[test]
fn selinux_enforcing_disables_labels_only_for_host_file_commands() {
let selinux_info = "[name=seccomp,profile=default name=selinux name=cgroupns]";
for subcommand in [
"install-mcp",
"install-hooks",
"setup-agent",
"install-instructions",
"install-skills",
"uninstall",
"backup",
"restore",
] {
let args = run_wrapper_with_fake_selinux(&[subcommand], selinux_info, "Enforcing");
assert!(
args.contains("--security-opt\nlabel=disable"),
"{subcommand} writes bind-mounted host files and needs the scoped \
SELinux exception; got {args}"
);
}
let args = run_wrapper_with_fake_selinux(&["status"], selinux_info, "Enforcing");
assert!(
!args.contains("label=disable"),
"thin-client commands must retain SELinux label confinement; got {args}"
);
}
#[cfg(unix)]
#[test]
fn selinux_label_exception_requires_enforcement_and_daemon_support() {
let selinux_info = "[name=seccomp,profile=default name=selinux name=cgroupns]";
let args = run_wrapper_with_fake_selinux(&["install-mcp"], selinux_info, "Permissive");
assert!(
!args.contains("label=disable"),
"permissive hosts do not need a label exception; got {args}"
);
let args = run_wrapper_with_fake_selinux(
&["install-mcp"],
"[name=seccomp,profile=default name=cgroupns]",
"Enforcing",
);
assert!(
!args.contains("label=disable"),
"a daemon without SELinux support must not receive SELinux options; got {args}"
);
}
#[cfg(unix)]
#[test]
fn posix_wrapper_forwards_subscription_oauth_tokens_without_putting_values_in_argv() {
let tokens = [
("ANTHROPIC_OAUTH_TOKEN", "oauth-canary-primary"),
("CLAUDE_CODE_OAUTH_TOKEN", "oauth-canary-fallback"),
];
let args = run_wrapper_with_fake_docker_and_forwarded_env(
&["llm-test", "--provider", "anthropic-oauth"],
"[name=seccomp,profile=default]",
&tokens,
);
let args: Vec<&str> = args.lines().collect();
for (name, value) in tokens {
assert!(
args.windows(2).any(|pair| pair == ["-e", name]),
"wrapper must forward {name} by name; got {args:?}"
);
assert!(
!args.iter().any(|arg| arg.contains(value)),
"wrapper must not put the value of {name} in Docker argv"
);
}
}
// The Windows mirror of macos_wrapper_routes_urls_by_real_subcommand: Docker
// Desktop gives Linux containers no host networking on Windows either, so the
// helper container cannot reach the host-published server over loopback.
#[cfg(windows)]
#[test]
fn windows_wrapper_routes_urls_by_real_subcommand() {
for subcommand in ["install-mcp", "install-hooks", "setup-agent"] {
let args = run_powershell_wrapper(&[subcommand]);
assert!(
!args.contains("AI_MEMORY_SERVER_URL=http://host.docker.internal:49374"),
"{subcommand} renders host-side config and must keep loopback defaults; got {args}"
);
}
let args = run_powershell_wrapper(&["status"]);
assert!(
args.contains("AI_MEMORY_SERVER_URL=http://host.docker.internal:49374"),
"thin-client commands must reach the host server through Docker Desktop; got {args}"
);
let args = run_powershell_wrapper(&["search", "install-hooks"]);
assert!(
args.contains("AI_MEMORY_SERVER_URL=http://host.docker.internal:49374"),
"only the actual subcommand should control URL routing; got {args}"
);
let args = run_powershell_wrapper(&["--config", "C:\\tmp\\config.toml", "install-hooks"]);
assert!(
!args.contains("AI_MEMORY_SERVER_URL=http://host.docker.internal:49374"),
"global options before install-hooks must not hide the real subcommand; got {args}"
);
// A homelab/remote server is configured through the environment, and the
// helper container must not be redirected back at the local Docker host.
let args =
run_powershell_wrapper_with_server_url(&["status"], Some("http://192.168.1.50:49374"));
assert!(
!args.contains("AI_MEMORY_SERVER_URL=http://host.docker.internal:49374"),
"an explicit AI_MEMORY_SERVER_URL must win over the Docker Desktop alias; got {args}"
);
assert!(
args.contains("-e AI_MEMORY_SERVER_URL"),
"an explicit AI_MEMORY_SERVER_URL must still be forwarded by name; got {args}"
);
}
#[cfg(windows)]
#[test]
fn powershell_wrapper_forwards_subscription_oauth_tokens_without_putting_values_in_argv() {
let tmp = tempfile::tempdir().unwrap();
let docker_args = tmp.path().join("docker-args.txt");
let docker = tmp.path().join("docker.cmd");
std::fs::write(
&docker,
"@echo off\r\n>\"%AI_MEMORY_TEST_DOCKER_ARGS%\" echo %*\r\nexit /b 0\r\n",
)
.unwrap();
let tokens = [
("ANTHROPIC_OAUTH_TOKEN", "oauth-canary-primary"),
("CLAUDE_CODE_OAUTH_TOKEN", "oauth-canary-fallback"),
];
let mut command = Command::new(ai_memory_test_support::powershell_exe());
command
// -ExecutionPolicy Bypass: `-File` loads a script from disk, and execution policy
// governs script *files*, so on a machine left at the Windows client default of
// `Restricted` the unsigned bin/ai-memory.ps1 is refused (`UnauthorizedAccess`) and
// the wrapper never runs — failing this test for a reason unrelated to what it
// checks. The other in-repo invocation (render_shared.rs) uses `-Command`, which is
// not policy-gated and therefore needs no override; the generated hook commands pass
// Bypass defensively. GitHub's runner is permissive enough that this passes there
// today, so the fix is for running the suite on a stock Windows install.
.args([
"-NoLogo",
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy",
"Bypass",
"-File",
])
.arg(repo_root().join("bin/ai-memory.ps1"))
.args(["llm-test", "--provider", "anthropic-oauth"])
.env("AI_MEMORY_DOCKER", &docker)
.env("AI_MEMORY_TEST_DOCKER_ARGS", &docker_args);
for (name, value) in tokens {
command.env(name, value);
}
let output = command.output().unwrap();
assert!(
output.status.success(),
"PowerShell wrapper failed: stdout={} stderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let args = std::fs::read_to_string(docker_args).unwrap();
for (name, value) in tokens {
assert!(
args.contains(&format!("-e {name}")),
"PowerShell wrapper must forward {name} by name; got {args}"
);
assert!(
!args.contains(value),
"PowerShell wrapper must not put the value of {name} in Docker argv"
);
}
}
#[cfg(unix)]
#[test]
fn podman_rootless_and_selinux_are_detected_without_the_docker_only_field() {
// Podman answers nothing for `{{.SecurityOptions}}`, so both gates used to
// read as "rootful, no SELinux" and every host-file write died with
// Permission denied. Both adjustments are required: neither alone makes
// the write land.
for subcommand in [
"install-mcp",
"install-hooks",
"setup-agent",
"install-instructions",
"install-skills",
"uninstall",
"backup",
"restore",
] {
let args = run_wrapper_with_fake_podman(&[subcommand], true, true, "Enforcing");
assert!(
args.contains("-u\n0:0"),
"{subcommand} needs the rootless UID remap under podman too; got {args}"
);
assert!(
args.contains("--security-opt\nlabel=disable"),
"{subcommand} needs the scoped SELinux exception under podman too; got {args}"
);
}
let args = run_wrapper_with_fake_podman(&["status"], true, true, "Enforcing");
assert!(
!args.contains("-u\n0:0") && !args.contains("label=disable"),
"thin-client commands touch only the named volume and must stay \
confined under podman as well; got {args}"
);
}
#[cfg(unix)]
#[test]
fn podman_gates_still_respect_engine_and_host_state() {
// The fallback reports what podman reports; it must not hard-code "yes".
let args = run_wrapper_with_fake_podman(&["install-mcp"], false, true, "Enforcing");
assert!(
!args.contains("-u\n0:0"),
"rootful podman maps the host UID directly and must keep it; got {args}"
);
let args = run_wrapper_with_fake_podman(&["install-mcp"], true, false, "Enforcing");
assert!(
!args.contains("label=disable"),
"an engine without SELinux support must not receive SELinux options; got {args}"
);
let args = run_wrapper_with_fake_podman(&["install-mcp"], true, true, "Permissive");
assert!(
!args.contains("label=disable"),
"permissive hosts do not need a label exception; got {args}"
);
}
#[cfg(unix)]
#[test]
fn bootstrap_gets_host_file_treatment_because_it_reads_the_repo() {
// bootstrap only reads host files, but an unmapped UID blocks reads just
// as hard: it degrades silently to "no .git found at /work" and then dies
// with Permission denied. Same gates as the writers, on both engines.
let args = run_wrapper_with_fake_podman(&["bootstrap"], true, true, "Enforcing");
assert!(
args.contains("-u\n0:0") && args.contains("--security-opt\nlabel=disable"),
"bootstrap reads the repo bind-mounted at /work and needs both \
adjustments; got {args}"
);
let args = run_wrapper_with_fake_selinux(
&["bootstrap"],
"[name=seccomp,profile=default name=selinux name=cgroupns]",
"Enforcing",
);
assert!(
args.contains("--security-opt\nlabel=disable"),
"the same read applies under Docker on an SELinux host; got {args}"
);
}
#[cfg(unix)]
#[test]
fn explicit_config_gets_host_file_treatment() {
let args = run_wrapper_with_fake_podman(
&["--config", "/tmp/config.toml", "status"],
true,
true,
"Enforcing",
);
assert!(
args.contains("-u\n0:0") && args.contains("--security-opt\nlabel=disable"),
"an explicit config is read through a host bind and needs both adjustments; got {args}"
);
let args = run_wrapper_with_fake_podman(
&["--config=/tmp/config.toml", "status"],
true,
true,
"Enforcing",
);
assert!(
args.contains("-u\n0:0") && args.contains("--security-opt\nlabel=disable"),
"the equals form of --config must receive the same treatment; got {args}"
);
}
#[cfg(unix)]
#[test]
fn custom_data_dir_makes_thin_commands_touch_host_files() {
let args = run_wrapper_with_fake_docker_env(
&["status"],
FakeInfo::Podman {
rootless: true,
selinux: true,
},
Some("Linux"),
None,
Some("Enforcing"),
&[("AI_MEMORY_DATA_DIR", "/tmp")],
);
assert!(
args.contains("-u\n0:0") && args.contains("--security-opt\nlabel=disable"),
"a thin command backed by a host data directory needs both adjustments; got {args}"
);
assert!(
args.contains("/tmp:/data"),
"the custom data directory must remain the /data bind; got {args}"
);
}
}