mirror of
https://github.com/akitaonrails/ai-memory.git
synced 2026-10-02 03:24:46 +08:00
2811 lines
102 KiB
Rust
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}"
|
|
);
|
|
}
|
|
}
|