Skip to main content

vivacity_core/
dirs.rs

1//! `config.vendor-dir` and `config.bin-dir`, resolved like
2//! `Config::get('vendor-dir' | 'bin-dir')` (Composer 2.10.3):
3//!
4//! - the `COMPOSER_VENDOR_DIR` / `COMPOSER_BIN_DIR` environment first, else
5//!   the project's `config`, else the global config, else the defaults
6//!   `vendor` and `{$vendor-dir}/bin`;
7//! - `{$vendor-dir}` / `{$bin-dir}` placeholders substituted (`process`),
8//!   the result stripped of trailing `/` and `\` (`rtrim`), then made
9//!   absolute against the project directory without canonicalisation
10//!   (`Config::realpath`).
11//!
12//! Every consumer of the directory (`LibraryInstaller`, `BinaryInstaller`,
13//! `AutoloadGenerator`, `FilesystemRepository`) `realpath`s it before
14//! deriving a path written to disk, so the project-relative normalised form
15//! kept here (`lib/vendor`, `vendor/bin`) is what those outputs depend on.
16//!
17//! The forms the differential harness does not cover are refused as scope
18//! issues rather than half-supported: absolute paths, `..`-prefixed paths,
19//! the project root itself, `~/`, `$VAR` / `%VAR%` prefixes
20//! (`Platform::expandPath`), other placeholders, non-string values.
21
22use crate::pathutil::{is_absolute_path, normalize_path};
23use serde_json::Value;
24use std::path::{Path, PathBuf};
25
26/// The two directories, project-relative and normalised.
27#[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    /// From the root manifest, the environment and the global config.
44    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    /// `resolve` with the environment and the global config injected.
53    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            // `getComposerEnv`: an unset or empty variable is no value.
61            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    /// `vendor` by default.
89    pub fn vendor_rel(&self) -> &str {
90        &self.vendor_rel
91    }
92
93    /// `vendor/bin` by default.
94    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    /// `<vendor-dir>/composer`.
107    pub fn composer_dir(&self, root: &Path) -> PathBuf {
108        root.join(&self.vendor_rel).join("composer")
109    }
110}
111
112/// `Config::process`: `{$key}` replaced by the resolved value of `key`.
113/// Only `vendor-dir` is meaningful inside `bin-dir` (and the default
114/// depends on it); anything else is refused.
115fn 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
141/// The project-relative normalised form, or the scope issue.
142fn 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}