Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions integration-tests/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ shell-words.workspace = true
whoami.workspace = true
zstd.workspace = true
tar.workspace = true
indoc.workspace = true
test-case.workspace = true

[dev-dependencies]
camino-tempfile-ext.workspace = true
Expand Down
106 changes: 106 additions & 0 deletions integration-tests/tests/integration/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,15 @@

use camino::{Utf8Path, Utf8PathBuf};
use fixture_data::{models::RunProperty, nextest_tests::EXPECTED_TEST_SUITES};
use indoc::indoc;
use integration_tests::{
env::set_env_vars,
nextest_cli::{CargoNextestCli, CargoNextestOutput},
};
use nextest_metadata::{BuildPlatform, NextestExitCode, TestListSummary};
use std::{borrow::Cow, fs::File, io::Write};
use target_spec::{Platform, summaries::TargetFeaturesSummary};
use test_case::test_case;

mod fixtures;
mod interceptor;
Expand Down Expand Up @@ -1769,3 +1771,107 @@ fn test_rustc_version_verbose_errors() {
);
}
}

#[test_case(
indoc! {r#"
[store]
dir = "my-store"

[profile.default]
fail-fast = false
[profile.default.junit]
path = "junit.xml"
"#},
"my-store/default",
false
; "store dir simple"
)]
#[test_case(
indoc! {r#"
[store]
dir = { dir = "my-store-ws", relative-to = "workspace-root" }

[profile.default]
fail-fast = false
[profile.default.junit]
path = "junit.xml"
"#},
"my-store-ws/default",
false
; "store dir relative to workspace root"
)]
#[test_case(
indoc! {r#"
[store]
dir = { dir = "my-store-target", relative-to = "target-dir" }

[profile.default]
fail-fast = false
[profile.default.junit]
path = "junit.xml"
"#},
"target/my-store-target/default",
false
; "store dir relative to target dir"
)]
#[test_case(
indoc! {r#"
[store]
dir = { dir = "emoji🚀test", relative-to = "target-dir" }

[profile.default]
fail-fast = false
[profile.default.junit]
path = "junit.xml"
"#},
"target/emoji🚀test/default",
false
; "store dir with unicode"
)]
#[test_case(
indoc! {r#"
[store]
dir = { dir = "../escape-store", relative-to = "target-dir" }
"#},
"escape-store/default",
true
; "store dir with parent directory traversal should fail"
)]
fn test_store_dir_config(config_contents: &str, relative_to_workspace: &str, should_fail: bool) {
set_env_vars();
let p = TempProject::new().unwrap();

// Create config file
let config_path = p.workspace_root().join(".config/nextest.toml");
std::fs::create_dir_all(config_path.parent().unwrap()).unwrap();
std::fs::write(&config_path, config_contents).unwrap();

let target_dir = p.workspace_root().join("target");

// Run nextest
let output = CargoNextestCli::for_test()
.args([
"--manifest-path",
p.manifest_path().as_str(),
"run",
"--workspace",
"--all-targets",
])
.env("CARGO_TARGET_DIR", &target_dir)
.unchecked(true) // Expecting a non-zero exit code because some tests fail
.output();

if should_fail {
assert!(
!output.exit_status.success(),
"config with directory traversal should fail to parse: {output}"
);
} else {
let store_dir = p.workspace_root().join(relative_to_workspace);
assert!(
store_dir.is_dir(),
"store directory should be created at {:?}",
store_dir
);
}
}
160 changes: 160 additions & 0 deletions nextest-runner/src/config/elements/store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -121,9 +121,112 @@ enum StoreRelativeTo {

#[cfg(test)]
mod tests {
use crate::config::{
core::NextestConfig,
utils::test_helpers::{build_platforms, temp_workspace},
};

use super::*;
use camino_tempfile::tempdir;
use indoc::indoc;
use nextest_filtering::ParseContext;
use test_case::test_case;

#[test_case(
"",
Utf8PathBuf::from("target"),
Ok(Utf8PathBuf::from("nextest/default")),
false
; "no config"
)]
#[test_case(
indoc! {r#"
[store]
dir = { path = "nexte", relative-to = "tig" }
"#},
Utf8PathBuf::from("target"),
Err("does not have variant constructor tig"),
true
; "invalid relative-to"
)]
#[test_case(
indoc! {r#"
[store]
dir = "my-store"
"#} ,
Utf8PathBuf::from("target"),
Ok(Utf8PathBuf::from("my-store/default")),
false
; "valid dir"
)]
#[test_case(
indoc! {r#"
[store]
dir = { dir = "my-store", relative-to = "target-dir" }
"#},
Utf8PathBuf::from("target"),
Ok(Utf8PathBuf::from("my-store/default")),
true
; "valid target dir"
)]
#[test_case(
indoc! {r#"
[store]
dir = { dir = "store", relative-to = "workspace-root" }
"#},
Utf8PathBuf::from(""),
Ok(Utf8PathBuf::from("store/default")),
true
; "valid workspace root"
)]
fn store_config_deserialization(
config_contents: &str,
target_dir: Utf8PathBuf,
expected: Result<Utf8PathBuf, &str>,
relative_to: bool,
) {
let workspace_dir = tempdir().unwrap();

let graph = temp_workspace(&workspace_dir, config_contents);

let pcx = ParseContext::new(&graph);

let nextest_config_result = NextestConfig::from_sources(
graph.workspace().root(),
&pcx,
None,
&[][..],
&Default::default(),
);

match expected {
Ok(expected_default) => {
let nextest_config = nextest_config_result.expect("config file should parse");

let store_dir = nextest_config
.profile("default")
.expect("default profile should exist")
.into_evaluatable(&build_platforms())
.store_dir(&target_dir);

if relative_to {
assert!(store_dir.ends_with(target_dir.join(expected_default)));
} else {
assert!(store_dir.ends_with(expected_default));
}
}

Err(expected_err_str) => {
let err_str = format!("{:?}", nextest_config_result.unwrap_err());

assert!(
err_str.contains(expected_err_str),
"expected error string not found: {err_str}",
)
}
}
}

#[test_case(
r#"dir = "target/nextest""#,
"/workspace",
Expand Down Expand Up @@ -152,6 +255,35 @@ mod tests {
"/tmp/archive-target/nextest"
; "relative to remapped target dir"
)]
#[test_case(
r#"dir = { dir = "emoji🚀test", relative-to = "workspace-root" }"#,
"/workspace",
"/workspace/target",
"/workspace/emoji🚀test"
; "emoji unicode path"
)]
#[test_case(
r#"dir = { dir = "café/naïve", relative-to = "target-dir" }"#,
"/workspace",
"/tmp/target",
"/tmp/target/café/naïve"
; "accented unicode path"
)]
// Edge case boundary conditions
#[test_case(
r#"dir = { dir = "", relative-to = "workspace-root" }"#,
"/workspace",
"/workspace/target",
"/workspace"
; "empty path component"
)]
#[test_case(
r#"dir = { dir = ".", relative-to = "target-dir" }"#,
"/workspace",
"/tmp/target",
"/tmp/target"
; "current directory reference"
)]
fn test_store_dir_resolution(
toml: &str,
workspace_root: &str,
Expand All @@ -163,4 +295,32 @@ mod tests {
config.resolve_store_dir(Utf8Path::new(workspace_root), Utf8Path::new(target_dir));
assert_eq!(resolved, Utf8Path::new(expected));
}

#[test]
fn test_store_dir_escape_target_dir() {
let result = toml::from_str::<StoreConfigImpl>(
r#"dir = { dir = "../escape-test", relative-to = "target-dir" }"#,
);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("expected a relative path with no parent components")
);
}

#[test]
fn test_store_dir_parent_dir_in_path() {
let result = toml::from_str::<StoreConfigImpl>(
r#"dir = { dir = "sub/../sneaky", relative-to = "workspace-root" }"#,
);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("expected a relative path with no parent components")
);
}
}