Skip to main content

shuyi_cli/export/
paths.rs

1//! Nothing of the build machine in a package: the build's paths remapped
2//! to short fixed names ([`remapped_flags`]), and the built binary searched
3//! for what is left of them ([`search`]).
4//!
5//! Cargo's `trim-paths` isn't stable on the pinned Rust, so the remapping
6//! is rustc's `--remap-path-prefix`, passed through
7//! `CARGO_ENCODED_RUSTFLAGS` after the flags already in it or in
8//! `RUSTFLAGS`. That variable wins over the `rustflags` of a game's
9//! `.cargo/config.toml`, which these builds then don't get.
10
11use std::ffi::OsString;
12use std::path::{Path, PathBuf};
13
14/// What starts the error [`search`]'s finding gives: `web::run` returns
15/// it as text, and the web exporter tells it from a failed build by it.
16pub const FOUND: &str = "the build holds a path of the build machine";
17
18/// Cargo's flags variable, its arguments separated by `\x1f`.
19pub const ENCODED: &str = "CARGO_ENCODED_RUSTFLAGS";
20
21/// The folders a release build is remapped from: the workspace's root
22/// (to the package's name), each package of `outside` (a folder outside
23/// the workspace that the build reads by a path, to its name),
24/// `CARGO_HOME` (`/cargo`), `RUSTUP_HOME` (`/rustup`) and the target
25/// folder (`/target`), in that order. rustc tries the last first, so a
26/// folder inside another one still gets its own name.
27pub fn remaps(
28    workspace: &Path,
29    target: &Path,
30    name: &str,
31    outside: &[(PathBuf, String)],
32) -> Vec<(PathBuf, String)> {
33    let mut remaps = vec![(workspace.to_path_buf(), name.to_owned())];
34    remaps.extend(outside.iter().cloned());
35    if let Some(cargo) = cargo_home() {
36        remaps.push((cargo, "/cargo".to_owned()));
37    }
38    if let Some(rustup) = rustup_home() {
39        remaps.push((rustup, "/rustup".to_owned()));
40    }
41    remaps.push((target.to_path_buf(), "/target".to_owned()));
42    remaps
43}
44
45/// `cargo metadata`'s answer, the fields [`outside`] reads.
46#[derive(serde::Deserialize)]
47struct Metadata {
48    packages: Vec<Package>,
49}
50
51#[derive(serde::Deserialize)]
52struct Package {
53    name: String,
54    source: Option<String>,
55    manifest_path: PathBuf,
56}
57
58/// The packages the project at `root` builds from a path outside its
59/// `workspace` (the engine, when a game names it by `path = "…"`), each
60/// folder with its name, by `cargo metadata`. None if it doesn't run.
61pub fn outside(root: &Path, workspace: &Path) -> Vec<(PathBuf, String)> {
62    std::process::Command::new(crate::cargo())
63        .args(["metadata", "--format-version", "1"])
64        .current_dir(root)
65        .output()
66        .ok()
67        .filter(|output| output.status.success())
68        .map(|output| outside_of(&String::from_utf8_lossy(&output.stdout), workspace))
69        .unwrap_or_default()
70}
71
72/// [`outside`] from `cargo metadata`'s answer, `json`: the packages with
73/// no source (read from a path) whose folder isn't in `workspace`.
74fn outside_of(json: &str, workspace: &Path) -> Vec<(PathBuf, String)> {
75    let Ok(meta) = serde_json::from_str::<Metadata>(json) else {
76        return Vec::new();
77    };
78    let mut found: Vec<(PathBuf, String)> = meta
79        .packages
80        .into_iter()
81        .filter(|p| p.source.is_none())
82        .filter_map(|p| Some((p.manifest_path.parent()?.to_path_buf(), p.name)))
83        .filter(|(dir, _)| !dir.starts_with(workspace))
84        .collect();
85    found.sort();
86    found
87}
88
89/// The home folder: `HOME`, else `USERPROFILE`.
90pub fn home() -> Option<PathBuf> {
91    ["HOME", "USERPROFILE"]
92        .into_iter()
93        .filter_map(std::env::var_os)
94        .find(|value| !value.is_empty())
95        .map(PathBuf::from)
96}
97
98/// `CARGO_HOME`, else `.cargo` in the home folder.
99pub fn cargo_home() -> Option<PathBuf> {
100    std::env::var_os("CARGO_HOME")
101        .filter(|value| !value.is_empty())
102        .map(PathBuf::from)
103        .or_else(|| home().map(|home| home.join(".cargo")))
104}
105
106/// `RUSTUP_HOME`, else `.rustup` in the home folder.
107fn rustup_home() -> Option<PathBuf> {
108    std::env::var_os("RUSTUP_HOME")
109        .filter(|value| !value.is_empty())
110        .map(PathBuf::from)
111        .or_else(|| home().map(|home| home.join(".rustup")))
112}
113
114/// The value of [`ENCODED`] for a build with `remaps`: the flags already
115/// in `encoded` (that variable), else those in `plain` (`RUSTFLAGS`, split
116/// on white space), then a `--remap-path-prefix` for each remap.
117pub fn remapped_flags(
118    encoded: Option<OsString>,
119    plain: Option<OsString>,
120    remaps: &[(PathBuf, String)],
121) -> String {
122    let mut flags: Vec<String> = match (encoded, plain) {
123        (Some(encoded), _) if !encoded.is_empty() => encoded
124            .to_string_lossy()
125            .split('\x1f')
126            .map(str::to_owned)
127            .collect(),
128        (_, Some(plain)) => plain
129            .to_string_lossy()
130            .split_whitespace()
131            .map(str::to_owned)
132            .collect(),
133        _ => Vec::new(),
134    };
135    for (from, to) in remaps {
136        flags.push(format!("--remap-path-prefix={}={to}", from.display()));
137    }
138    flags.join("\x1f")
139}
140
141/// [`remapped_flags`] with this process's variables.
142pub fn remapped_flags_here(remaps: &[(PathBuf, String)]) -> String {
143    remapped_flags(
144        std::env::var_os(ENCODED),
145        std::env::var_os("RUSTFLAGS"),
146        remaps,
147    )
148}
149
150/// The paths a package must not hold: the home folder, `CARGO_HOME` and
151/// the `roots` (the project's and the workspace's).
152pub fn needles(roots: &[&Path]) -> Vec<PathBuf> {
153    let mut needles: Vec<PathBuf> = home().into_iter().chain(cargo_home()).collect();
154    needles.extend(roots.iter().map(|root| root.to_path_buf()));
155    needles
156}
157
158/// The first of `needles` that `bytes` hold, with either slash (or both)
159/// and in any case of its ASCII letters, as the needle is written. A path
160/// of fewer than two parts (`/`, `C:\`) is never looked for.
161pub fn search(bytes: &[u8], needles: &[PathBuf]) -> Option<String> {
162    let alike = |byte: &u8| match byte.to_ascii_lowercase() {
163        b'\\' => b'/',
164        other => other,
165    };
166    let haystack: Vec<u8> = bytes.iter().map(alike).collect();
167    for needle in needles {
168        let text = needle.to_string_lossy();
169        let trimmed = text.trim_end_matches(['/', '\\']);
170        let parts = trimmed.split(['/', '\\']).filter(|p| !p.is_empty()).count();
171        if parts < 2 {
172            continue;
173        }
174        let form: Vec<u8> = trimmed.as_bytes().iter().map(alike).collect();
175        if contains(&haystack, &form) {
176            return Some(trimmed.to_owned());
177        }
178    }
179    None
180}
181
182/// Whether `haystack` holds `needle`.
183fn contains(haystack: &[u8], needle: &[u8]) -> bool {
184    !needle.is_empty()
185        && haystack
186            .windows(needle.len())
187            .any(|window| window == needle)
188}
189
190/// [`search`] on the file at `file`, as the error the package is refused
191/// with.
192pub fn check_file(file: &Path, needles: &[PathBuf]) -> Result<(), String> {
193    let bytes = std::fs::read(file).map_err(|e| format!("read {}: {e}", file.display()))?;
194    match search(&bytes, needles) {
195        Some(found) => Err(format!(
196            "{FOUND}: {} holds {found}; the package is refused",
197            file.display()
198        )),
199        None => Ok(()),
200    }
201}
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206
207    #[test]
208    fn the_search_finds_a_home_folder_with_either_slash_in_any_case() {
209        let needles = [PathBuf::from(r"C:\Users\Someone")];
210        for bytes in [
211            &b"panicked at C:\\Users\\Someone\\.cargo\\registry\\src\\x.rs"[..],
212            b"at C:/Users/Someone/.cargo/registry",
213            b"at c:\\users\\someone\\x",
214            b"at C:\\Users/Someone/x",
215        ] {
216            assert_eq!(
217                search(bytes, &needles).as_deref(),
218                Some(r"C:\Users\Someone")
219            );
220        }
221        let unix = [PathBuf::from("/home/runner/")];
222        assert_eq!(
223            search(b"/home/runner/work/x/src/lib.rs", &unix).as_deref(),
224            Some("/home/runner")
225        );
226        assert_eq!(
227            search(b"\\home\\runner\\work", &unix).as_deref(),
228            Some("/home/runner")
229        );
230    }
231
232    #[test]
233    fn the_search_passes_the_remapped_names_and_skips_a_root() {
234        let needles = [
235            PathBuf::from("/home/runner"),
236            PathBuf::from("/"),
237            PathBuf::from(r"C:\"),
238            PathBuf::new(),
239        ];
240        let remapped = b"/cargo/registry/src/index.crates.io-1/x/src/lib.rs \
241                         /rustup/toolchains/x /target/release/build game/crates/core/src/lib.rs";
242        assert_eq!(search(remapped, &needles), None);
243        assert_eq!(search(b"", &needles), None);
244    }
245
246    #[test]
247    fn a_file_with_a_path_is_refused_by_name() {
248        let dir = std::env::temp_dir().join(format!("shuyi-paths-{}", std::process::id()));
249        std::fs::create_dir_all(&dir).unwrap();
250        let file = dir.join("game.wasm");
251        std::fs::write(&file, b"\0\0/home/runner/work/a.rs\0").unwrap();
252        let error = check_file(&file, &[PathBuf::from("/home/runner")]).unwrap_err();
253        assert!(error.starts_with(FOUND), "{error}");
254        assert!(error.ends_with("holds /home/runner; the package is refused"));
255        std::fs::write(&file, b"/cargo/registry").unwrap();
256        assert_eq!(check_file(&file, &[PathBuf::from("/home/runner")]), Ok(()));
257        assert!(check_file(&dir.join("missing"), &[]).is_err());
258        std::fs::remove_dir_all(&dir).unwrap();
259    }
260
261    #[test]
262    fn the_flags_go_after_the_ones_already_set_and_never_replace_them() {
263        let remaps = [
264            (PathBuf::from("/work/game"), "game".to_owned()),
265            (PathBuf::from("/home/me/.cargo"), "/cargo".to_owned()),
266        ];
267        let tail =
268            "--remap-path-prefix=/work/game=game\x1f--remap-path-prefix=/home/me/.cargo=/cargo";
269        // RUSTFLAGS, split on white space.
270        assert_eq!(
271            remapped_flags(None, Some("-D warnings".into()), &remaps),
272            format!("-D\x1fwarnings\x1f{tail}")
273        );
274        // CARGO_ENCODED_RUSTFLAGS wins over RUSTFLAGS, as in cargo.
275        assert_eq!(
276            remapped_flags(
277                Some("-C\x1ftarget-cpu=native".into()),
278                Some("-D warnings".into()),
279                &remaps
280            ),
281            format!("-C\x1ftarget-cpu=native\x1f{tail}")
282        );
283        // An empty encoded variable is no flags: RUSTFLAGS then counts.
284        assert_eq!(
285            remapped_flags(Some("".into()), Some("-D warnings".into()), &remaps),
286            format!("-D\x1fwarnings\x1f{tail}")
287        );
288        assert_eq!(remapped_flags(None, None, &remaps), tail);
289    }
290
291    #[test]
292    fn the_packages_read_by_a_path_outside_the_workspace_are_remapped_to_their_names() {
293        let json = r#"{"packages": [
294            {"name": "game", "source": null, "manifest_path": "/w/game/Cargo.toml"},
295            {"name": "shuyi-core", "source": null, "manifest_path": "/e/crates/core/Cargo.toml"},
296            {"name": "serde", "source": "registry+https://x", "manifest_path": "/c/serde/Cargo.toml"}
297        ]}"#;
298        assert_eq!(
299            outside_of(json, Path::new("/w")),
300            [(PathBuf::from("/e/crates/core"), "shuyi-core".to_owned())]
301        );
302        assert_eq!(outside_of("not json", Path::new("/w")), []);
303        let outside = [(PathBuf::from("/e/crates/core"), "shuyi-core".to_owned())];
304        let remaps = remaps(Path::new("/w"), Path::new("/w/target"), "game", &outside);
305        assert_eq!(remaps[1], outside[0]);
306        // This crate, from Hello, Grid's folder: the engine is its
307        // workspace, so nothing is outside.
308        let example = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../examples/hello-grid");
309        let workspace = crate::web::workspace_root(&example);
310        assert_eq!(super::outside(&example, &workspace), []);
311    }
312
313    #[test]
314    fn the_remaps_end_with_the_target_folder_and_start_with_the_workspace() {
315        let remaps = remaps(Path::new("/w"), Path::new("/w/target"), "game", &[]);
316        assert_eq!(remaps[0], (PathBuf::from("/w"), "game".to_owned()));
317        assert_eq!(
318            remaps.last().unwrap(),
319            &(PathBuf::from("/w/target"), "/target".to_owned())
320        );
321    }
322}