Skip to main content

osdk_core/backend/
rust.rs

1//! Rust backend: delegates to rustup (installing rustup into a self-contained
2//! home if absent), driving it with the fastest mirror as RUSTUP_DIST_SERVER.
3//! This is the hybrid strategy: reuse the official manager for the complex
4//! channel/component/target matrix rather than reimplementing it.
5
6use std::collections::BTreeMap;
7use std::path::PathBuf;
8
9use async_trait::async_trait;
10
11use crate::backend::{Backend, Ctx, InstallCtx};
12use crate::error::{Error, Result};
13use crate::pipeline::{self, HashAlgo};
14use crate::process;
15use crate::source::Source;
16use crate::version::{ToolVersion, VersionInfo};
17
18pub struct RustBackend;
19
20impl RustBackend {
21    /// Env for driving rustup within osdk's self-contained homes + mirror.
22    /// `source` is the chosen dist server (fastest under auto, or the pin).
23    fn rustup_env(ctx: &Ctx, source: Option<&Source>) -> BTreeMap<String, String> {
24        let mut env = BTreeMap::new();
25        env.insert(
26            "RUSTUP_HOME".to_string(),
27            ctx.dirs.rustup_home().display().to_string(),
28        );
29        env.insert(
30            "CARGO_HOME".to_string(),
31            ctx.dirs.cargo_home().display().to_string(),
32        );
33        if let Some(src) = source {
34            env.insert("RUSTUP_DIST_SERVER".to_string(), src.download_url.clone());
35            if let Some(update_root) = &src.index_url {
36                env.insert("RUSTUP_UPDATE_ROOT".to_string(), update_root.clone());
37            }
38        }
39        env
40    }
41
42    fn rustup_bin(ctx: &Ctx) -> PathBuf {
43        let exe = if ctx.platform.os == crate::platform::Os::Windows {
44            "rustup.exe"
45        } else {
46            "rustup"
47        };
48        ctx.dirs.cargo_home().join("bin").join(exe)
49    }
50
51    pub fn run_rustup(
52        ctx: &Ctx,
53        args: &[&str],
54        cwd: Option<&std::path::Path>,
55    ) -> Result<std::process::Output> {
56        let rustup = Self::rustup_bin(ctx);
57        if !rustup.is_file() {
58            return Err(Error::other(format!(
59                "isolated rustup is missing at {}; install Rust first",
60                rustup.display()
61            )));
62        }
63        let env = Self::rustup_env(ctx, None);
64        process::output(&rustup.display().to_string(), args, &env, cwd)
65    }
66
67    pub fn reconcile_markers(ctx: &Ctx) -> Result<(usize, usize)> {
68        let toolchains = ctx.dirs.rustup_home().join("toolchains");
69        let marker_root = ctx.dirs.installs.join("rust");
70        crate::dirs::create_dir_all(&marker_root)?;
71        let mut created = 0usize;
72        let mut removed = 0usize;
73        let mut actual = std::collections::BTreeSet::new();
74        if toolchains.is_dir() {
75            for entry in
76                std::fs::read_dir(&toolchains).map_err(|error| Error::io(&toolchains, error))?
77            {
78                let entry = entry.map_err(|error| Error::io(&toolchains, error))?;
79                let file_type = entry
80                    .file_type()
81                    .map_err(|error| Error::io(entry.path(), error))?;
82                if !file_type.is_dir() && !file_type.is_symlink() {
83                    continue;
84                }
85                let name = entry.file_name().to_string_lossy().into_owned();
86                actual.insert(name.clone());
87                let marker = marker_root.join(&name);
88                if !marker.join(".osdk-complete").is_file() {
89                    crate::dirs::create_dir_all(&marker)?;
90                    std::fs::write(marker.join(".osdk-complete"), b"")
91                        .map_err(|error| Error::io(marker.join(".osdk-complete"), error))?;
92                    created += 1;
93                }
94            }
95        }
96        if marker_root.is_dir() {
97            for entry in
98                std::fs::read_dir(&marker_root).map_err(|error| Error::io(&marker_root, error))?
99            {
100                let entry = entry.map_err(|error| Error::io(&marker_root, error))?;
101                let name = entry.file_name().to_string_lossy().into_owned();
102                if name.starts_with('.') || actual.contains(&name) {
103                    continue;
104                }
105                if entry.path().is_dir() {
106                    std::fs::remove_dir_all(entry.path())
107                        .map_err(|error| Error::io(entry.path(), error))?;
108                    removed += 1;
109                }
110            }
111        }
112        Ok((created, removed))
113    }
114
115    pub fn record_linked_toolchain(ctx: &Ctx, name: &str, path: &std::path::Path) -> Result<()> {
116        let marker = ctx.dirs.install_path("rust", name);
117        crate::dirs::create_dir_all(&marker)?;
118        let canonical = dunce::canonicalize(path).map_err(|error| Error::io(path, error))?;
119        std::fs::write(marker.join(".osdk-linked"), canonical.display().to_string())
120            .map_err(|error| Error::io(marker.join(".osdk-linked"), error))?;
121        std::fs::write(marker.join(".osdk-complete"), b"")
122            .map_err(|error| Error::io(marker.join(".osdk-complete"), error))
123    }
124
125    pub fn linked_path(ctx: &Ctx, name: &str) -> Option<PathBuf> {
126        let marker = ctx.dirs.install_path("rust", name).join(".osdk-linked");
127        let value = std::fs::read_to_string(marker).ok()?;
128        let path = PathBuf::from(value.trim());
129        path.is_dir().then_some(path)
130    }
131
132    /// Ensure rustup is installed into osdk's isolated cargo home.
133    async fn ensure_rustup(ctx: &Ctx, sources: &[Source]) -> Result<PathBuf> {
134        let local = Self::rustup_bin(ctx);
135        if local.exists() {
136            return Ok(local);
137        }
138        let file_name = format!("rustup-init{}", ctx.platform.os.exe_suffix());
139        let triple = ctx.platform.llvm_triple();
140        let cached = ctx
141            .dirs
142            .downloads()
143            .join("rustup")
144            .join(&triple)
145            .join(&file_name);
146        if ctx.config.settings.offline && !cached.exists() {
147            return Err(Error::other(
148                "offline rustup bootstrap cache miss (install rust once without --offline)",
149            ));
150        }
151        let mut last_err = None;
152
153        for source in sources {
154            let update_root = source
155                .index_url
156                .clone()
157                .unwrap_or_else(|| crate::http::join_url(&source.download_url, "rustup"));
158            let url = crate::http::join_url(&update_root, &format!("dist/{triple}/{file_name}"));
159            let checksum_url = format!("{url}.sha256");
160            let checksum = match crate::http::get_cached_text(ctx, &checksum_url).await {
161                Ok(body) => match pipeline::verify::parse_sha256_token(&body) {
162                    Some(checksum) => checksum,
163                    None => {
164                        last_err = Some(Error::other(format!(
165                            "invalid rustup-init checksum from {checksum_url}"
166                        )));
167                        continue;
168                    }
169                },
170                Err(error) => {
171                    last_err = Some(error);
172                    continue;
173                }
174            };
175            match pipeline::download::download(
176                &ctx.client,
177                &url,
178                &cached,
179                "rustup-init",
180                ctx.show_progress,
181            )
182            .await
183            {
184                Ok(()) => {
185                    if let Err(error) = pipeline::verify::verify_file(
186                        &cached,
187                        &checksum,
188                        HashAlgo::Sha256,
189                        &file_name,
190                    ) {
191                        let _ = std::fs::remove_file(&cached);
192                        last_err = Some(error);
193                        continue;
194                    }
195                }
196                Err(error) => {
197                    last_err = Some(error);
198                    continue;
199                }
200            }
201            #[cfg(unix)]
202            {
203                use std::os::unix::fs::PermissionsExt;
204                let permissions = std::fs::Permissions::from_mode(0o755);
205                std::fs::set_permissions(&cached, permissions)
206                    .map_err(|error| Error::io(&cached, error))?;
207            }
208            let env = Self::rustup_env(ctx, Some(source));
209            process::run(
210                &cached.display().to_string(),
211                &[
212                    "-y",
213                    "--no-modify-path",
214                    "--profile",
215                    "minimal",
216                    "--default-toolchain",
217                    "none",
218                ],
219                &env,
220                None,
221            )?;
222            if local.exists() {
223                return Ok(local);
224            }
225            last_err = Some(Error::other(format!(
226                "rustup-init completed but {} was not created",
227                local.display()
228            )));
229        }
230
231        Err(last_err.unwrap_or_else(|| Error::NoUsableSource {
232            tool: "rust".to_string(),
233            tried: sources.len(),
234        }))
235    }
236}
237
238#[async_trait]
239impl Backend for RustBackend {
240    fn id(&self) -> &str {
241        "rust"
242    }
243
244    fn aliases(&self) -> &[&str] {
245        &["rustup"]
246    }
247
248    fn default_sources(&self) -> Vec<Source> {
249        vec![
250            Source::official("official", "https://static.rust-lang.org")
251                .with_index("https://static.rust-lang.org/rustup"),
252            Source::mirror("rsproxy", "https://rsproxy.cn", 5)
253                .with_index("https://rsproxy.cn/rustup"),
254            Source::mirror("tuna", "https://mirrors.tuna.tsinghua.edu.cn/rustup", 10)
255                .with_index("https://mirrors.tuna.tsinghua.edu.cn/rustup/rustup"),
256        ]
257    }
258
259    fn probe_url(&self, _ctx: &Ctx, source: &Source) -> Option<String> {
260        // The stable channel manifest is a good representative object.
261        Some(crate::http::join_url(
262            &source.download_url,
263            "dist/channel-rust-stable.toml",
264        ))
265    }
266
267    async fn list_remote_versions(&self, _ctx: &Ctx) -> Result<Vec<VersionInfo>> {
268        // rustup resolves channels/versions itself; we surface the common
269        // channels plus let exact versions pass through resolve_version.
270        Ok(vec![
271            VersionInfo::stable("stable"),
272            VersionInfo::stable("beta"),
273            VersionInfo::stable("nightly"),
274        ])
275    }
276
277    async fn resolve_version(
278        &self,
279        _ctx: &Ctx,
280        req: &crate::version::ToolRequest,
281    ) -> Result<ToolVersion> {
282        use crate::version::VersionSpec;
283        // Pass channels/versions straight through to rustup.
284        let version = match &req.spec {
285            VersionSpec::Latest => "stable".to_string(),
286            VersionSpec::Exact(v) => v.clone(),
287            VersionSpec::Prefix(p) => p.clone(),
288            VersionSpec::Range(requirement) => requirement.clone(),
289            VersionSpec::Lts(_) => "stable".to_string(),
290            VersionSpec::System => "stable".to_string(),
291        };
292        let mut tv = ToolVersion::new(self.id(), version);
293        tv.options = req.options.clone();
294        Ok(tv)
295    }
296
297    async fn install(&self, ictx: &InstallCtx<'_>, tv: &ToolVersion) -> Result<()> {
298        let ctx = ictx.ctx;
299        let sources = crate::source::select::ranked_source_list(ctx, self).await?;
300        let rustup = Self::ensure_rustup(ctx, &sources).await?;
301        let source = sources.first();
302        let env = Self::rustup_env(ctx, source);
303        if let Some(s) = source {
304            tracing::info!(source = %s.id, dist = %s.download_url, "{}", crate::i18n::tr("log.rustup_dist_server"));
305        }
306        let toolchain_bin = Self::toolchain_dir(ctx, &tv.version).join("bin");
307        let rustc = toolchain_bin.join(format!("rustc{}", ctx.platform.os.exe_suffix()));
308        if ctx.config.settings.offline && !rustc.exists() {
309            return Err(Error::other(format!(
310                "offline rust toolchain cache miss for {}",
311                tv.version
312            )));
313        }
314
315        // Install the toolchain. Optional profile/components/targets via options.
316        let profile = tv
317            .options
318            .get("profile")
319            .map(|s| s.as_str())
320            .unwrap_or("default");
321        let mut args: Vec<String> = vec![
322            "toolchain".into(),
323            "install".into(),
324            tv.version.clone(),
325            "--profile".into(),
326            profile.into(),
327        ];
328        if let Some(components) = tv.options.get("components") {
329            for c in components.split(',').filter(|s| !s.is_empty()) {
330                args.push("--component".into());
331                args.push(c.to_string());
332            }
333        }
334        if let Some(targets) = tv.options.get("targets") {
335            for t in targets.split(',').filter(|s| !s.is_empty()) {
336                args.push("--target".into());
337                args.push(t.to_string());
338            }
339        }
340        let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
341        // rustup may fail at the final "link proxies into CARGO_HOME" step when
342        // we point CARGO_HOME at osdk's dir (rustup expects to own it). That's
343        // non-fatal for us: we generate our own shims to the toolchain bin dir.
344        // So we tolerate a nonzero exit iff the toolchain dir materialized.
345        if !rustc.exists() {
346            let run_res = process::run(&rustup.display().to_string(), &arg_refs, &env, None);
347            if let Err(e) = run_res {
348                if !rustc.exists() {
349                    return Err(e);
350                }
351                tracing::debug!("rustup returned an error but the toolchain installed; continuing");
352            }
353        }
354
355        // Record the install so list_installed/bin_paths work: rustup manages
356        // toolchains under RUSTUP_HOME/toolchains/<name>. We create a marker
357        // dir under our installs tree pointing at that toolchain.
358        let install_dir = ctx.dirs.install_path(self.id(), &tv.version);
359        crate::dirs::create_dir_all(&install_dir)?;
360        std::fs::write(install_dir.join(".osdk-complete"), b"")
361            .map_err(|e| Error::io(install_dir.join(".osdk-complete"), e))?;
362        Ok(())
363    }
364
365    async fn uninstall(&self, ctx: &Ctx, tv: &ToolVersion) -> Result<()> {
366        let toolchain_dir = Self::toolchain_dir(ctx, &tv.version);
367        if toolchain_dir.exists() {
368            let rustup = Self::rustup_bin(ctx);
369            if !rustup.exists() {
370                return Err(Error::other(format!(
371                    "cannot uninstall rust toolchain {}: isolated rustup is missing",
372                    tv.version
373                )));
374            }
375            let env = Self::rustup_env(ctx, None);
376            process::run(
377                &rustup.display().to_string(),
378                &["toolchain", "uninstall", &tv.version],
379                &env,
380                None,
381            )?;
382        }
383        let marker_dir = ctx.dirs.install_path(self.id(), &tv.version);
384        if marker_dir.exists() {
385            std::fs::remove_dir_all(&marker_dir).map_err(|error| Error::io(&marker_dir, error))?;
386        }
387        Ok(())
388    }
389
390    fn bin_paths(&self, ctx: &Ctx, tv: &ToolVersion) -> Result<Vec<PathBuf>> {
391        if let Some(linked) = Self::linked_path(ctx, &tv.version) {
392            return Ok(vec![linked.join("bin"), ctx.dirs.cargo_home().join("bin")]);
393        }
394        // rustup toolchain bins live at RUSTUP_HOME/toolchains/<name>/bin.
395        let toolchain_dir = Self::toolchain_dir(ctx, &tv.version);
396        Ok(vec![
397            toolchain_dir.join("bin"),
398            ctx.dirs.cargo_home().join("bin"),
399        ])
400    }
401
402    fn exec_env(&self, ctx: &Ctx, _tv: &ToolVersion) -> Result<BTreeMap<String, String>> {
403        let mut env = BTreeMap::new();
404        env.insert(
405            "RUSTUP_HOME".to_string(),
406            ctx.dirs.rustup_home().display().to_string(),
407        );
408        env.insert(
409            "CARGO_HOME".to_string(),
410            ctx.dirs.cargo_home().display().to_string(),
411        );
412        Ok(env)
413    }
414
415    fn bin_names(&self, _ctx: &Ctx, _tv: &ToolVersion) -> Result<Vec<String>> {
416        Ok(vec![
417            "rustc".into(),
418            "cargo".into(),
419            "rustup".into(),
420            "clippy-driver".into(),
421            "rustfmt".into(),
422        ])
423    }
424
425    fn idiomatic_files(&self) -> &[&str] {
426        &["rust-toolchain.toml", "rust-toolchain"]
427    }
428}
429
430impl RustBackend {
431    /// The rustup toolchain directory for a version/channel. rustup expands a
432    /// bare channel like `stable` into `stable-<host-triple>`.
433    pub(crate) fn toolchain_dir(ctx: &Ctx, version: &str) -> PathBuf {
434        Self::toolchain_dir_for_dirs(&ctx.dirs, ctx.platform, version)
435    }
436
437    pub(crate) fn toolchain_dir_for_dirs(
438        dirs: &crate::dirs::Dirs,
439        platform: crate::platform::Platform,
440        version: &str,
441    ) -> PathBuf {
442        let toolchains = dirs.rustup_home().join("toolchains");
443        let exact = toolchains.join(version);
444        if exact.exists() {
445            return exact;
446        }
447        // Try `<channel>-<host-triple>`.
448        let triple = platform.llvm_triple();
449        let with_triple = toolchains.join(format!("{version}-{triple}"));
450        if with_triple.exists() {
451            return with_triple;
452        }
453        // Best-effort: find a toolchain dir that starts with the channel name.
454        if let Ok(rd) = std::fs::read_dir(&toolchains) {
455            for entry in rd.flatten() {
456                let name = entry.file_name().to_string_lossy().to_string();
457                if name.starts_with(version) {
458                    return entry.path();
459                }
460            }
461        }
462        exact
463    }
464
465    /// Resolve an installed toolchain without fuzzy prefix matching. Native
466    /// package installs bind compiler bytes into their durable identity, so an
467    /// ambiguous prefix must never select an arbitrary toolchain tree.
468    pub(crate) fn exact_toolchain_dir_for_dirs(
469        dirs: &crate::dirs::Dirs,
470        platform: crate::platform::Platform,
471        version: &str,
472    ) -> Option<PathBuf> {
473        let toolchains = dirs.rustup_home().join("toolchains");
474        let exact = toolchains.join(version);
475        if exact.is_dir() {
476            return Some(exact);
477        }
478        let triple = platform.llvm_triple();
479        let with_triple = toolchains.join(format!("{version}-{triple}"));
480        if with_triple.is_dir() {
481            return Some(with_triple);
482        }
483        let prefix = format!("{version}-");
484        let mut matches = std::fs::read_dir(toolchains)
485            .ok()?
486            .flatten()
487            .filter(|entry| entry.path().is_dir())
488            .filter(|entry| entry.file_name().to_string_lossy().starts_with(&prefix))
489            .map(|entry| entry.path());
490        let selected = matches.next()?;
491        matches.next().is_none().then_some(selected)
492    }
493}
494
495#[cfg(test)]
496mod tests {
497    #[cfg(unix)]
498    #[tokio::test]
499    async fn uninstall_delegates_to_isolated_rustup() {
500        use super::*;
501        use std::os::unix::fs::PermissionsExt;
502
503        let temp = tempfile::tempdir().unwrap();
504        let dirs = crate::dirs::Dirs::resolve_from(|key| match key {
505            "OSDK_DATA_DIR" => Some(temp.path().join("data").display().to_string()),
506            "OSDK_CACHE_DIR" => Some(temp.path().join("cache").display().to_string()),
507            "OSDK_CONFIG_DIR" => Some(temp.path().join("config").display().to_string()),
508            _ => None,
509        })
510        .unwrap();
511        dirs.ensure().unwrap();
512        let log = temp.path().join("rustup.log");
513        let rustup = dirs.cargo_home().join("bin/rustup");
514        std::fs::create_dir_all(rustup.parent().unwrap()).unwrap();
515        std::fs::write(
516            &rustup,
517            format!("#!/bin/sh\nprintf '%s' \"$*\" > '{}'\n", log.display()),
518        )
519        .unwrap();
520        std::fs::set_permissions(&rustup, std::fs::Permissions::from_mode(0o755)).unwrap();
521        std::fs::create_dir_all(dirs.rustup_home().join("toolchains/stable/bin")).unwrap();
522        let marker = dirs.install_path("rust", "stable");
523        std::fs::create_dir_all(&marker).unwrap();
524        std::fs::write(marker.join(".osdk-complete"), b"").unwrap();
525
526        let ctx = Ctx {
527            dirs: dirs.clone(),
528            platform: crate::platform::Platform::current(),
529            config: crate::config::Config {
530                settings: Default::default(),
531                sources: Default::default(),
532                tools: Default::default(),
533                tool_configs: Default::default(),
534                global_tools: Default::default(),
535                global_tool_configs: Default::default(),
536                tool_origins: Default::default(),
537                aliases: Default::default(),
538                project_config_path: None,
539            },
540            client: reqwest::Client::new(),
541            cas: std::sync::Arc::new(crate::store::Cas::new(dirs.store.clone())),
542            show_progress: false,
543        };
544        RustBackend
545            .uninstall(&ctx, &ToolVersion::new("rust", "stable"))
546            .await
547            .unwrap();
548
549        assert_eq!(
550            std::fs::read_to_string(log).unwrap(),
551            "toolchain uninstall stable"
552        );
553        assert!(!marker.exists());
554    }
555}