Skip to content

Commit 1807f21

Browse files
Quote launch process command paths (#239)
1 parent 587e07f commit 1807f21

File tree

5 files changed

+96
-58
lines changed

5 files changed

+96
-58
lines changed

Cargo.lock

+7
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

buildpacks/dotnet/CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1010
### Changed
1111

1212
- The buildpack now sanitizes launch process type names, based on project assembly names, by filtering out invalid characters. ([#237](https://github.com/heroku/buildpacks-dotnet/pull/237))
13+
- Launch process commands with paths containing special characters (including spaces) are now properly quoted. ([#239](https://github.com/heroku/buildpacks-dotnet/pull/239))
1314

1415
## [0.3.5] - 2025-03-19
1516

buildpacks/dotnet/Cargo.toml

+1
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ semver = "1.0"
2222
serde = "1"
2323
serde_json = "1"
2424
sha2 = "0.10"
25+
shell-words = "1.1.0"
2526

2627
[dev-dependencies]
2728
libcnb-test = "0.28"

buildpacks/dotnet/src/dotnet/project.rs

+1-1
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ struct Metadata {
4848
assembly_name: Option<String>,
4949
}
5050

51-
#[derive(Debug, PartialEq)]
51+
#[derive(Debug, PartialEq, Clone, Copy)]
5252
pub(crate) enum ProjectType {
5353
ConsoleApplication,
5454
WebApplication,

buildpacks/dotnet/src/launch_process.rs

+86-57
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ use crate::dotnet::project::ProjectType;
22
use crate::dotnet::solution::Solution;
33
use crate::Project;
44
use libcnb::data::launch::{Process, ProcessBuilder, ProcessType};
5-
use std::path::PathBuf;
5+
use std::path::{Path, PathBuf};
66

77
/// Detects processes in a solution's projects
88
pub(crate) fn detect_solution_processes(solution: &Solution) -> Vec<Process> {
@@ -23,23 +23,36 @@ fn project_launch_process(solution: &Solution, project: &Project) -> Option<Proc
2323
}
2424
let relative_executable_path = relative_executable_path(solution, project);
2525

26+
let command = build_command(&relative_executable_path, project.project_type);
27+
28+
Some(ProcessBuilder::new(project_process_type(project), ["bash", "-c", &command]).build())
29+
}
30+
31+
/// Constructs the shell command for launching the process
32+
fn build_command(relative_executable_path: &Path, project_type: ProjectType) -> String {
33+
let parent_dir = relative_executable_path
34+
.parent()
35+
.expect("Executable path should always have a parent directory")
36+
.to_str()
37+
.expect("Path should be valid UTF-8");
38+
39+
let file_name = relative_executable_path
40+
.file_name()
41+
.expect("Executable path should always have a file name")
42+
.to_str()
43+
.expect("Path should be valid UTF-8");
44+
2645
let mut command = format!(
2746
"cd {}; ./{}",
28-
relative_executable_path
29-
.parent()
30-
.expect("Path to always have a parent directory")
31-
.display(),
32-
relative_executable_path
33-
.file_name()
34-
.expect("Path to never terminate in `..`")
35-
.to_string_lossy()
47+
shell_words::quote(parent_dir),
48+
shell_words::quote(file_name)
3649
);
3750

38-
if project.project_type == ProjectType::WebApplication {
51+
if project_type == ProjectType::WebApplication {
3952
command.push_str(" --urls http://*:$PORT");
4053
}
4154

42-
Some(ProcessBuilder::new(project_process_type(project), ["bash", "-c", &command]).build())
55+
command
4356
}
4457

4558
/// Returns a sanitized process type name, ensuring it is always valid
@@ -56,9 +69,9 @@ fn relative_executable_path(solution: &Solution, project: &Project) -> PathBuf {
5669
solution
5770
.path
5871
.parent()
59-
.expect("Solution path to have a parent"),
72+
.expect("Solution file should be in a directory"),
6073
)
61-
.expect("Project to be nested in solution parent directory")
74+
.expect("Executable path should inside the solution's directory")
6275
.to_path_buf()
6376
}
6477

@@ -88,16 +101,24 @@ mod tests {
88101
use libcnb::data::process_type;
89102
use std::path::PathBuf;
90103

104+
fn create_test_project(path: &str, assembly_name: &str, project_type: ProjectType) -> Project {
105+
Project {
106+
path: PathBuf::from(path),
107+
target_framework: "net9.0".to_string(),
108+
project_type,
109+
assembly_name: assembly_name.to_string(),
110+
}
111+
}
112+
91113
#[test]
92114
fn test_detect_solution_processes_web_app() {
93115
let solution = Solution {
94116
path: PathBuf::from("/tmp/foo.sln"),
95-
projects: vec![Project {
96-
path: PathBuf::from("/tmp/bar/bar.csproj"),
97-
target_framework: "net9.0".to_string(),
98-
project_type: ProjectType::WebApplication,
99-
assembly_name: "bar".to_string(),
100-
}],
117+
projects: vec![create_test_project(
118+
"/tmp/bar/bar.csproj",
119+
"bar",
120+
ProjectType::WebApplication,
121+
)],
101122
};
102123

103124
let expected_processes = vec![Process {
@@ -116,23 +137,22 @@ mod tests {
116137
}
117138

118139
#[test]
119-
fn test_detect_solution_processes_console_app() {
140+
fn test_detect_solution_processes_with_spaces() {
120141
let solution = Solution {
121-
path: PathBuf::from("/tmp/foo.sln"),
122-
projects: vec![Project {
123-
path: PathBuf::from("/tmp/bar/bar.csproj"),
124-
target_framework: "net9.0".to_string(),
125-
project_type: ProjectType::ConsoleApplication,
126-
assembly_name: "bar".to_string(),
127-
}],
142+
path: PathBuf::from("/tmp/My Solution With Spaces.sln"),
143+
projects: vec![create_test_project(
144+
"/tmp/My Project With Spaces/project.csproj",
145+
"My App",
146+
ProjectType::ConsoleApplication,
147+
)],
128148
};
129149

130150
let expected_processes = vec![Process {
131-
r#type: process_type!("bar"),
151+
r#type: process_type!("MyApp"),
132152
command: vec![
133153
"bash".to_string(),
134154
"-c".to_string(),
135-
"cd bar/bin/publish; ./bar".to_string(),
155+
"cd 'My Project With Spaces/bin/publish'; ./'My App'".to_string(),
136156
],
137157
args: vec![],
138158
default: false,
@@ -143,28 +163,31 @@ mod tests {
143163
}
144164

145165
#[test]
146-
fn test_project_launch_process_non_executable() {
166+
fn test_relative_executable_path() {
147167
let solution = Solution {
148-
path: PathBuf::from("/tmp/foo.sln"),
149-
projects: vec![Project {
150-
path: PathBuf::from("/tmp/bar/bar.csproj"),
151-
target_framework: "net9.0".to_string(),
152-
project_type: ProjectType::Unknown,
153-
assembly_name: "bar".to_string(),
154-
}],
168+
path: PathBuf::from("/tmp/solution.sln"),
169+
projects: vec![],
155170
};
156171

157-
assert!(detect_solution_processes(&solution).is_empty());
172+
let project = create_test_project(
173+
"/tmp/project/project.csproj",
174+
"TestApp",
175+
ProjectType::ConsoleApplication,
176+
);
177+
178+
assert_eq!(
179+
relative_executable_path(&solution, &project),
180+
PathBuf::from("project/bin/publish/TestApp")
181+
);
158182
}
159183

160184
#[test]
161185
fn test_project_executable_path() {
162-
let project = Project {
163-
path: PathBuf::from("/tmp/project/project.csproj"),
164-
target_framework: "net9.0".to_string(),
165-
project_type: ProjectType::ConsoleApplication,
166-
assembly_name: "TestApp".to_string(),
167-
};
186+
let project = create_test_project(
187+
"/tmp/project/project.csproj",
188+
"TestApp",
189+
ProjectType::ConsoleApplication,
190+
);
168191

169192
assert_eq!(
170193
project_executable_path(&project),
@@ -173,22 +196,28 @@ mod tests {
173196
}
174197

175198
#[test]
176-
fn test_relative_executable_path() {
177-
let solution = Solution {
178-
path: PathBuf::from("/tmp/solution.sln"),
179-
projects: vec![],
180-
};
199+
fn test_build_command_with_spaces() {
200+
let executable_path = PathBuf::from("some/project with spaces/bin/publish/My App");
181201

182-
let project = Project {
183-
path: PathBuf::from("/tmp/project/project.csproj"),
184-
target_framework: "net9.0".to_string(),
185-
project_type: ProjectType::ConsoleApplication,
186-
assembly_name: "TestApp".to_string(),
187-
};
202+
assert_eq!(
203+
build_command(&executable_path, ProjectType::ConsoleApplication),
204+
"cd 'some/project with spaces/bin/publish'; ./'My App'"
205+
);
188206

189207
assert_eq!(
190-
relative_executable_path(&solution, &project),
191-
PathBuf::from("project/bin/publish/TestApp")
208+
build_command(&executable_path, ProjectType::WebApplication),
209+
"cd 'some/project with spaces/bin/publish'; ./'My App' --urls http://*:$PORT"
210+
);
211+
}
212+
213+
#[test]
214+
fn test_build_command_with_special_chars() {
215+
let executable_path =
216+
PathBuf::from("some/project with #special$chars/bin/publish/My-App+v1.2_Release!");
217+
218+
assert_eq!(
219+
build_command(&executable_path, ProjectType::ConsoleApplication),
220+
"cd 'some/project with #special$chars/bin/publish'; ./My-App+v1.2_Release!"
192221
);
193222
}
194223

0 commit comments

Comments
 (0)