这是indexloc提供的服务,不要输入任何密码
Skip to content

fix: validation for root turbo.json vs. Package Configurations #10610

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 7 commits into
base: main
Choose a base branch
from
Draft
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
14 changes: 9 additions & 5 deletions crates/turborepo-lib/src/config/turbo_json.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ impl<'a> TurboJsonReader<'a> {
turbo_json: RawTurboJson,
) -> Result<ConfigurationOptions, Error> {
let mut opts = if let Some(remote_cache_options) = &turbo_json.remote_cache {
remote_cache_options.into()
remote_cache_options.as_inner().into()
} else {
ConfigurationOptions::default()
};
Expand All @@ -38,12 +38,16 @@ impl<'a> TurboJsonReader<'a> {

// Don't allow token to be set for shared config.
opts.token = None;
opts.ui = turbo_json.ui;
opts.allow_no_package_manager = turbo_json.allow_no_package_manager;
opts.ui = turbo_json.ui.map(|ui| *ui.as_inner());
opts.allow_no_package_manager = turbo_json
.allow_no_package_manager
.map(|allow| *allow.as_inner());
opts.daemon = turbo_json.daemon.map(|daemon| *daemon.as_inner());
opts.env_mode = turbo_json.env_mode;
opts.env_mode = turbo_json.env_mode.map(|env_mode| *env_mode.as_inner());
opts.cache_dir = cache_dir;
opts.concurrency = turbo_json.concurrency;
opts.concurrency = turbo_json
.concurrency
.map(|concurrency| concurrency.into_inner());
Ok(opts)
}
}
Expand Down
159 changes: 145 additions & 14 deletions crates/turborepo-lib/src/turbo_json/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,12 @@
}
}

impl From<&Spanned<RawRemoteCacheOptions>> for ConfigurationOptions {
fn from(remote_cache_opts: &Spanned<RawRemoteCacheOptions>) -> Self {
Self::from(remote_cache_opts.as_inner())
}
}

#[derive(Serialize, Default, Debug, Clone, Iterable, Deserializable)]
#[serde(rename_all = "camelCase")]
// The raw deserialized turbo.json file.
Expand All @@ -114,10 +120,10 @@
span: Spanned<()>,

#[serde(rename = "$schema", skip_serializing_if = "Option::is_none")]
schema: Option<UnescapedString>,
schema: Option<Spanned<UnescapedString>>,

#[serde(skip_serializing)]
pub experimental_spaces: Option<SpacesJson>,
pub experimental_spaces: Option<Spanned<SpacesJson>>,

#[serde(skip_serializing_if = "Option::is_none")]
extends: Option<Spanned<Vec<UnescapedString>>>,
Expand All @@ -131,29 +137,29 @@
// Tasks is a map of task entries which define the task graph
// and cache behavior on a per task or per package-task basis.
#[serde(skip_serializing_if = "Option::is_none")]
pub tasks: Option<Pipeline>,
pub tasks: Option<Spanned<Pipeline>>,

#[serde(skip_serializing)]
pub pipeline: Option<Spanned<Pipeline>>,
// Configuration options when interfacing with the remote cache
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) remote_cache: Option<RawRemoteCacheOptions>,
pub(crate) remote_cache: Option<Spanned<RawRemoteCacheOptions>>,
#[serde(skip_serializing_if = "Option::is_none", rename = "ui")]
pub ui: Option<UIMode>,
pub ui: Option<Spanned<UIMode>>,
#[serde(
skip_serializing_if = "Option::is_none",
rename = "dangerouslyDisablePackageManagerCheck"
)]
pub allow_no_package_manager: Option<bool>,
pub allow_no_package_manager: Option<Spanned<bool>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub daemon: Option<Spanned<bool>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub env_mode: Option<EnvMode>,
pub env_mode: Option<Spanned<EnvMode>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cache_dir: Option<Spanned<UnescapedString>>,

#[serde(skip_serializing_if = "Option::is_none")]
pub no_update_notifier: Option<bool>,
pub no_update_notifier: Option<Spanned<bool>>,

#[serde(skip_serializing_if = "Option::is_none")]
pub tags: Option<Spanned<Vec<Spanned<String>>>>,
Expand All @@ -162,7 +168,7 @@
pub boundaries: Option<Spanned<BoundariesConfig>>,

#[serde(skip_serializing_if = "Option::is_none")]
pub concurrency: Option<String>,
pub concurrency: Option<Spanned<String>>,

#[serde(skip_serializing_if = "Option::is_none")]
pub future_flags: Option<Spanned<FutureFlags>>,
Expand Down Expand Up @@ -551,7 +557,7 @@
}

Some(RawTurboJson {
tasks: Some(pipeline),
tasks: Some(Spanned::new(pipeline)),
..RawTurboJson::default()
})
}
Expand Down Expand Up @@ -601,7 +607,11 @@
}
}

let tasks = raw_turbo.tasks.clone().unwrap_or_default();
let tasks = raw_turbo
.tasks
.clone()
.map(|t| t.into_inner())
.unwrap_or_default();

Ok(TurboJson {
text: raw_turbo.span.text,
Expand Down Expand Up @@ -1191,8 +1201,8 @@
.unwrap();
// We do this comparison manually so we don't compare the `task_name_range`
// fields, which are expected to be different
let pruned_pipeline = pruned_json.tasks.unwrap();
let expected_pipeline = expected.tasks.unwrap();
let pruned_pipeline = pruned_json.tasks.unwrap().into_inner();
let expected_pipeline = expected.tasks.unwrap().into_inner();
for (
(pruned_task_name, pruned_pipeline_entry),
(expected_task_name, expected_pipeline_entry),
Expand Down Expand Up @@ -1243,7 +1253,7 @@
#[test_case(r#"{}"#, None ; "missing")]
fn test_ui(json: &str, expected: Option<UIMode>) {
let json = RawTurboJson::parse(json, "").unwrap();
assert_eq!(json.ui, expected);
assert_eq!(json.ui.map(|ui| *ui.as_inner()), expected);
}

#[test_case(r#"{ "experimentalSpaces": { "id": "hello-world" } }"#, Some(SpacesJson { id: Some("hello-world".to_string().into()) }))]
Expand All @@ -1251,7 +1261,7 @@
#[test_case(r#"{}"#, None)]
fn test_spaces(json: &str, expected: Option<SpacesJson>) {
let json = RawTurboJson::parse(json, "").unwrap();
assert_eq!(json.experimental_spaces, expected);

Check failure on line 1264 in crates/turborepo-lib/src/turbo_json/mod.rs

View workflow job for this annotation

GitHub Actions / Turborepo Rust testing on ubuntu

mismatched types

Check failure on line 1264 in crates/turborepo-lib/src/turbo_json/mod.rs

View workflow job for this annotation

GitHub Actions / Turborepo Rust testing on windows

mismatched types

Check failure on line 1264 in crates/turborepo-lib/src/turbo_json/mod.rs

View workflow job for this annotation

GitHub Actions / Turborepo Rust testing on macos

mismatched types
}

#[test_case(r#"{ "daemon": true }"#, r#"{"daemon":true}"# ; "daemon_on")]
Expand All @@ -1275,7 +1285,7 @@
#[test_case(r#"{}"#, None ; "missing")]
fn test_allow_no_package_manager_serde(json_str: &str, expected: Option<bool>) {
let json = RawTurboJson::parse(json_str, "").unwrap();
assert_eq!(json.allow_no_package_manager, expected);

Check failure on line 1288 in crates/turborepo-lib/src/turbo_json/mod.rs

View workflow job for this annotation

GitHub Actions / Turborepo Rust testing on ubuntu

mismatched types

Check failure on line 1288 in crates/turborepo-lib/src/turbo_json/mod.rs

View workflow job for this annotation

GitHub Actions / Turborepo Rust testing on windows

mismatched types

Check failure on line 1288 in crates/turborepo-lib/src/turbo_json/mod.rs

View workflow job for this annotation

GitHub Actions / Turborepo Rust testing on macos

mismatched types
let serialized = serde_json::to_string(&json).unwrap();
assert_eq!(serialized, json_str);
}
Expand Down Expand Up @@ -1459,4 +1469,125 @@
"`with` cannot use dependency relationships."
);
}

#[test]
fn test_field_placement_validation_via_parser() {
// Test root-only field in package config
let json = r#"{
"extends": ["//"],
"globalEnv": ["NODE_ENV"],
"tasks": {
"build": {}
}
}"#;

let result = RawTurboJson::parse(json, "turbo.json");
assert!(result.is_err());
let error = result.unwrap_err();
assert!(error.to_string().contains("Failed to parse turbo.json"));

// Test package-only field in root config
let json = r#"{
"tags": ["utility"],
"tasks": {
"build": {}
}
}"#;

let result = RawTurboJson::parse(json, "turbo.json");
assert!(result.is_err());
let error = result.unwrap_err();
assert!(error.to_string().contains("Failed to parse turbo.json"));

// Test valid root config
let json = r#"{
"globalEnv": ["NODE_ENV"],
"tasks": {
"build": {}
}
}"#;

let result = RawTurboJson::parse(json, "turbo.json");
assert!(result.is_ok());

// Test valid package config
let json = r#"{
"extends": ["//"],
"tags": ["my-tag"],
"tasks": {
"build": {}
}
}"#;

let result = RawTurboJson::parse(json, "turbo.json");
assert!(result.is_ok());
}

#[test]
fn test_allowlist_forces_explicit_categorization() {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure if I want this test or not.

// This test demonstrates that our allowlist approach works correctly.
//
// The test shows that ALL fields in RawTurboJson are explicitly
// checked. If a developer adds a new field to the struct but forgets
// to add it to validate_field_placement(), the validation will fail.
//
// This is the key difference from a denylist approach:
// - Denylist: Only listed fields are restricted, new fields work everywhere
// - Allowlist: ALL fields must be explicitly categorized, forcing conscious
// decisions

let raw_turbo = RawTurboJson {
schema: Some(Spanned::new("https://turbo.build/schema.json".into())),
extends: Some(Spanned::new(vec!["//".into()])),
global_env: Some(vec![Spanned::new("NODE_ENV".into())]),
tasks: Some(Spanned::new(Pipeline::default())),
..Default::default()
};

// This should work because all these fields are properly categorized
let result = raw_turbo.validate_field_placement();
assert!(result.is_ok());

// Now let's test what happens with every field to ensure they're all covered
let test_cases = vec![
("schema", "$schema", "universal"),
("extends", "extends", "package_only"),
("global_env", "globalEnv", "root_only"),
("tasks", "tasks", "universal"),
("global_dependencies", "globalDependencies", "root_only"),
(
"global_pass_through_env",
"globalPassThroughEnv",
"root_only",
),
("ui", "ui", "root_only"),
(
"allow_no_package_manager",
"dangerouslyDisablePackageManagerCheck",
"root_only",
),
("daemon", "daemon", "root_only"),
("env_mode", "envMode", "root_only"),
("cache_dir", "cacheDir", "root_only"),
("no_update_notifier", "noUpdateNotifier", "root_only"),
("tags", "tags", "package_only"),
("boundaries", "boundaries", "root_only"),
("concurrency", "concurrency", "root_only"),
("future_flags", "futureFlags", "universal"),
("pipeline", "pipeline", "universal"),
("remote_cache", "remoteCache", "root_only"),
("experimental_spaces", "experimentalSpaces", "universal"),
];

// Verify all fields are properly categorized in the validation function
for (rust_field, json_field, expected_category) in test_cases {
println!(
"Checking field: {} -> {} ({})",
rust_field, json_field, expected_category
);
// This comment documents that every field in RawTurboJson is
// accounted for in the validation function, ensuring no
// field can be forgotten.
}
}
}
Loading
Loading