1use crate::pathutil::{is_absolute_path, normalize_path};
23use serde_json::Value;
24use std::path::{Path, PathBuf};
25
26#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct Dirs {
29 vendor_rel: String,
30 bin_rel: String,
31}
32
33impl Default for Dirs {
34 fn default() -> Self {
35 Dirs {
36 vendor_rel: "vendor".to_owned(),
37 bin_rel: "vendor/bin".to_owned(),
38 }
39 }
40}
41
42impl Dirs {
43 pub fn resolve(root_manifest: &Value) -> Result<Dirs, String> {
45 Self::resolve_with(
46 root_manifest,
47 |var| std::env::var(var).ok(),
48 crate::layout::global_config_value,
49 )
50 }
51
52 pub fn resolve_with(
54 root_manifest: &Value,
55 env: impl Fn(&str) -> Option<String>,
56 global: impl Fn(&str) -> Option<Value>,
57 ) -> Result<Dirs, String> {
58 let raw = |key: &str, default: &str| -> Result<String, String> {
59 let var = format!("COMPOSER_{}", key.to_ascii_uppercase().replace('-', "_"));
60 if let Some(v) = env(&var).filter(|v| !v.is_empty()) {
62 return Ok(v);
63 }
64 let configured = root_manifest
65 .get("config")
66 .and_then(|c| c.get(key))
67 .cloned()
68 .or_else(|| global(key));
69 match configured {
70 None => Ok(default.to_owned()),
71 Some(Value::String(s)) => Ok(s),
72 Some(other) => Err(format!("config {key} {other} is not a string")),
73 }
74 };
75 let vendor_raw = raw("vendor-dir", "vendor")?;
76 let vendor_rel = relative("vendor-dir", &substitute("vendor-dir", &vendor_raw, None)?)?;
77 let bin_raw = raw("bin-dir", "{$vendor-dir}/bin")?;
78 let bin_rel = relative(
79 "bin-dir",
80 &substitute("bin-dir", &bin_raw, Some(&vendor_rel))?,
81 )?;
82 Ok(Dirs {
83 vendor_rel,
84 bin_rel,
85 })
86 }
87
88 pub fn vendor_rel(&self) -> &str {
90 &self.vendor_rel
91 }
92
93 pub fn bin_rel(&self) -> &str {
95 &self.bin_rel
96 }
97
98 pub fn vendor_dir(&self, root: &Path) -> PathBuf {
99 root.join(&self.vendor_rel)
100 }
101
102 pub fn bin_dir(&self, root: &Path) -> PathBuf {
103 root.join(&self.bin_rel)
104 }
105
106 pub fn composer_dir(&self, root: &Path) -> PathBuf {
108 root.join(&self.vendor_rel).join("composer")
109 }
110}
111
112fn substitute(key: &str, value: &str, vendor_rel: Option<&str>) -> Result<String, String> {
116 let mut out = String::new();
117 let mut rest = value;
118 while let Some(start) = rest.find("{$") {
119 out.push_str(&rest[..start]);
120 let after = &rest[start + 2..];
121 let Some(end) = after.find('}') else {
122 out.push_str(&rest[start..]);
123 rest = "";
124 break;
125 };
126 let name = &after[..end];
127 match (name, vendor_rel) {
128 ("vendor-dir", Some(v)) => out.push_str(v),
129 _ => {
130 return Err(format!(
131 "config {key} \"{value}\" uses the placeholder {{${name}}}, which is not supported natively"
132 ))
133 }
134 }
135 rest = &after[end + 1..];
136 }
137 out.push_str(rest);
138 Ok(out)
139}
140
141fn relative(key: &str, value: &str) -> Result<String, String> {
143 let trimmed = value.trim_end_matches(['/', '\\']);
144 let refuse = |why: &str| {
145 Err(format!(
146 "config {key} \"{value}\" is not supported natively ({why})"
147 ))
148 };
149 if trimmed.starts_with("~/") || trimmed.starts_with("~\\") || trimmed == "~" {
150 return refuse("home-relative path");
151 }
152 if trimmed.starts_with('$') || trimmed.starts_with('%') {
153 return refuse("environment-variable prefix");
154 }
155 if is_absolute_path(trimmed) || (trimmed.len() >= 2 && trimmed.as_bytes()[1] == b':') {
156 return refuse("absolute path");
157 }
158 let rel = normalize_path(trimmed);
159 if rel.is_empty() || rel == "." {
160 return refuse("the project root itself");
161 }
162 if rel == ".." || rel.starts_with("../") {
163 return refuse("outside the project");
164 }
165 Ok(rel)
166}
167
168#[cfg(test)]
169mod tests {
170 use super::*;
171 use serde_json::json;
172
173 fn resolve(
174 manifest: Value,
175 env: &[(&str, &str)],
176 global: Option<Value>,
177 ) -> Result<Dirs, String> {
178 let env: Vec<(String, String)> = env
179 .iter()
180 .map(|(k, v)| (k.to_string(), v.to_string()))
181 .collect();
182 Dirs::resolve_with(
183 &manifest,
184 |var| env.iter().find(|(k, _)| k == var).map(|(_, v)| v.clone()),
185 |key| global.as_ref()?.get(key).cloned(),
186 )
187 }
188
189 fn dirs(vendor: &str, bin: &str) -> Dirs {
190 Dirs {
191 vendor_rel: vendor.to_owned(),
192 bin_rel: bin.to_owned(),
193 }
194 }
195
196 #[test]
197 fn defaults() {
198 assert_eq!(resolve(json!({}), &[], None).unwrap(), Dirs::default());
199 assert_eq!(Dirs::default(), dirs("vendor", "vendor/bin"));
200 }
201
202 #[test]
203 fn project_config_trailing_slashes_and_dot_prefix() {
204 let m =
205 json!({"config": {"vendor-dir": "upload/system/storage/vendor/", "bin-dir": "bin/"}});
206 assert_eq!(
207 resolve(m, &[], None).unwrap(),
208 dirs("upload/system/storage/vendor", "bin")
209 );
210 let m = json!({"config": {"vendor-dir": "./concrete/vendor"}});
211 assert_eq!(
212 resolve(m, &[], None).unwrap(),
213 dirs("concrete/vendor", "concrete/vendor/bin")
214 );
215 let m = json!({"config": {"vendor-dir": "./vendor/composer/vendor"}});
216 assert_eq!(
217 resolve(m, &[], None).unwrap(),
218 dirs("vendor/composer/vendor", "vendor/composer/vendor/bin")
219 );
220 }
221
222 #[test]
223 fn precedence_env_then_project_then_global() {
224 let m = json!({"config": {"vendor-dir": "lib/vendor"}});
225 let g = json!({"vendor-dir": "global/vendor", "bin-dir": "gbin"});
226 assert_eq!(
227 resolve(m.clone(), &[], Some(g.clone())).unwrap(),
228 dirs("lib/vendor", "gbin")
229 );
230 assert_eq!(
231 resolve(
232 m.clone(),
233 &[("COMPOSER_VENDOR_DIR", "env-vendor")],
234 Some(g.clone())
235 )
236 .unwrap(),
237 dirs("env-vendor", "gbin")
238 );
239 assert_eq!(
240 resolve(
241 m,
242 &[("COMPOSER_VENDOR_DIR", ""), ("COMPOSER_BIN_DIR", "b/")],
243 Some(g)
244 )
245 .unwrap(),
246 dirs("lib/vendor", "b")
247 );
248 }
249
250 #[test]
251 fn placeholder() {
252 let m = json!({"config": {"bin-dir": "{$vendor-dir}/tools"}});
253 assert_eq!(
254 resolve(m, &[("COMPOSER_VENDOR_DIR", "env-vendor")], None).unwrap(),
255 dirs("env-vendor", "env-vendor/tools")
256 );
257 let m = json!({"config": {"vendor-dir": "{$home}/v"}});
258 assert!(resolve(m, &[], None).unwrap_err().contains("{$home}"));
259 }
260
261 #[test]
262 fn refused_forms() {
263 for v in [
264 "/abs/vendor",
265 "C:/vendor",
266 "c:vendor",
267 "../vendor",
268 "..",
269 ".",
270 "./",
271 "~/vendor",
272 "$HOME/v",
273 "%HOME%/v",
274 "",
275 ] {
276 let m = json!({"config": {"vendor-dir": v}});
277 let err = resolve(m, &[], None).unwrap_err();
278 assert!(err.contains("not supported natively"), "{v}: {err}");
279 }
280 let m = json!({"config": {"vendor-dir": 3}});
281 assert!(resolve(m, &[], None).unwrap_err().contains("not a string"));
282 }
283}