Skip to main content

silicon_apps_client/
install.rs

1//! Checksum-verified transactional local installs with rollback and one updater.
2use crate::{
3    Client, Config, LocalState, Package, Release, Resolution, package, state as persistence,
4};
5use anyhow::{Context, Result, bail, ensure};
6use serde::{Deserialize, Serialize};
7use serde_json::json;
8use std::{fs, path::Path, str::FromStr, time::Duration};
9
10#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
11pub struct InstallSpec {
12    pub app_id: String,
13    pub channel: String,
14    pub version: Option<String>,
15}
16impl FromStr for InstallSpec {
17    type Err = anyhow::Error;
18    fn from_str(input: &str) -> Result<Self> {
19        let mut parts = input.split('@');
20        let name = parts.next().unwrap_or("");
21        let version = parts.next().map(str::to_owned);
22        ensure!(
23            parts.next().is_none(),
24            "invalid install spec `{input}`; use app, 'app>dev', 'app@1.2.3' or 'app>dev@1.2.3'"
25        );
26        let (id, channel) = if let Some(id) = name.strip_suffix(">dev") {
27            (id, "development")
28        } else {
29            (name, "production")
30        };
31        ensure!(
32            package::valid_existing_app_id(id),
33            "invalid app_id `{id}`; expected an existing 1–30 character lowercase app identifier"
34        );
35        if let Some(v) = &version {
36            ensure!(
37                package::strict_version(v),
38                "invalid version `{v}`; expected x.y.z"
39            );
40        }
41        Ok(Self {
42            app_id: id.into(),
43            channel: channel.into(),
44            version,
45        })
46    }
47}
48impl std::fmt::Display for InstallSpec {
49    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50        write!(
51            f,
52            "{}{}{}",
53            self.app_id,
54            if self.channel == "development" {
55                ">dev"
56            } else {
57                ""
58            },
59            self.version
60                .as_ref()
61                .map(|v| format!("@{v}"))
62                .unwrap_or_default()
63        )
64    }
65}
66#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct Installed {
68    pub app_id: String,
69    pub channel: String,
70    pub version: String,
71    pub target: String,
72    pub command: String,
73    pub release_id: String,
74    pub package_id: String,
75    pub sha256: String,
76    pub installed_at: String,
77    #[serde(default)]
78    pub server: String,
79    /// The API key that signed this release, when it came from a registry.
80    #[serde(default)]
81    pub signature_key_id: Option<String>,
82    /// The author key that also signed the package, if any.
83    #[serde(default)]
84    pub author_key_id: Option<String>,
85    /// The c:id or si:id that signed as author, if any.
86    #[serde(default)]
87    pub signed_by: Option<String>,
88    /// The install script's SHA-256, or `none` when the target has none.
89    /// Missing for installs made before this was recorded.
90    #[serde(default)]
91    pub install_script: Option<String>,
92}
93#[derive(Debug, Clone, Serialize)]
94pub struct InstallOutcome {
95    pub installed: Installed,
96    pub message: String,
97    pub count_recorded: bool,
98    pub warning: Option<String>,
99    /// One line to show the person: the install script changed, or the
100    /// author signature changed, since the installed version.
101    pub notice: Option<String>,
102}
103
104pub fn requires_channel_switch(state: &LocalState, spec: &InstallSpec) -> Result<Option<String>> {
105    Ok(state
106        .installed()?
107        .get(&spec.app_id)
108        .filter(|i| i.channel != spec.channel)
109        .map(|i| i.channel.clone()))
110}
111pub fn requires_source_switch(
112    state: &LocalState,
113    spec: &InstallSpec,
114    server: &str,
115) -> Result<Option<String>> {
116    let source = crate::auth::service_scope(server)?;
117    Ok(state
118        .installed()?
119        .get(&spec.app_id)
120        .filter(|i| i.server != source)
121        .map(|i| {
122            if i.server.is_empty() {
123                "an unscoped legacy registry".into()
124            } else {
125                i.server.clone()
126            }
127        }))
128}
129
130pub async fn install(
131    client: &Client,
132    state: &LocalState,
133    config: &Config,
134    spec: &InstallSpec,
135    allow_switch: bool,
136) -> Result<InstallOutcome> {
137    install_source(
138        PackageSource::Registry(client),
139        state,
140        config,
141        spec,
142        allow_switch,
143    )
144    .await
145}
146
147pub struct LocalArchive {
148    pub bytes: Vec<u8>,
149    pub sha256: String,
150}
151enum PackageSource<'a> {
152    Registry(&'a Client),
153    Archive(LocalArchive),
154}
155
156/// Bootstrap or intentionally install a local archive using an independently supplied checksum.
157/// Register its app/channel for the same automatic updater; no registry count is invented.
158pub async fn install_local(
159    state: &LocalState,
160    config: &Config,
161    spec: &InstallSpec,
162    archive: LocalArchive,
163    allow_switch: bool,
164) -> Result<InstallOutcome> {
165    install_source(
166        PackageSource::Archive(archive),
167        state,
168        config,
169        spec,
170        allow_switch,
171    )
172    .await
173}
174
175async fn install_source(
176    source: PackageSource<'_>,
177    state: &LocalState,
178    config: &Config,
179    spec: &InstallSpec,
180    allow_switch: bool,
181) -> Result<InstallOutcome> {
182    let _lock = state.lock("install")?;
183    let source_server = crate::auth::service_scope(match &source {
184        PackageSource::Registry(client) => client.base_url(),
185        PackageSource::Archive(_) => &config.server,
186    })?;
187    let mut installed = state.installed()?;
188    // The official CLI used `apps` through 0.1.8. Adopt that installation only
189    // when its registry and command identify this same service. Other registries
190    // and unrelated commands retain the normal ownership checks.
191    let legacy = spec.app_id == crate::APP_ID
192        && source_server == crate::DEFAULT_URL
193        && !installed.contains_key(crate::APP_ID)
194        && installed.get("apps").is_some_and(|old| {
195            old.server == crate::DEFAULT_URL
196                && matches!(old.command.as_str(), "apps" | "silicon-apps")
197        });
198    let previous_id = if legacy { "apps" } else { &spec.app_id };
199    if let Some(old) = installed.get(previous_id) {
200        ensure!(
201            old.server == source_server || allow_switch,
202            "{} was installed from {}; switching its registry to {} requires explicit confirmation (--yes)",
203            spec.app_id,
204            if old.server.is_empty() {
205                "an unscoped legacy registry"
206            } else {
207                &old.server
208            },
209            source_server
210        );
211        ensure!(
212            old.channel == spec.channel || allow_switch,
213            "{} is on {}; switching to {} requires confirmation (--yes)",
214            spec.app_id,
215            old.channel,
216            spec.channel
217        );
218    }
219    let target = package::current_target()?;
220    let client = match &source {
221        PackageSource::Registry(client) => Some(*client),
222        PackageSource::Archive(_) => None,
223    };
224    let (resolution, bytes) = match source {
225        PackageSource::Registry(client) => {
226            let resolution = client.resolve(spec, target).await?;
227            let bytes = client.download(&resolution).await?;
228            (resolution, bytes)
229        }
230        PackageSource::Archive(archive) => {
231            ensure!(
232                archive.sha256.len() == 64 && archive.sha256.bytes().all(|b| b.is_ascii_hexdigit()),
233                "--sha256 must be the trusted 64-character SHA-256 digest"
234            );
235            let digest = package::sha256(&archive.bytes);
236            ensure!(
237                digest == archive.sha256.to_ascii_lowercase(),
238                "SHA-256 checksum mismatch; local archive was not installed"
239            );
240            let manifest = package::inspect_archive(&archive.bytes)?;
241            let app_id = if legacy_manifest(&manifest, spec, &source_server) {
242                spec.app_id.clone()
243            } else {
244                manifest.app_id.clone()
245            };
246            let resolution = Resolution {
247                app_id: app_id.clone(),
248                release: Release {
249                    id: format!("bootstrap:{digest}"),
250                    app_id,
251                    channel: spec.channel.clone(),
252                    version: manifest.version,
253                    package_ids: vec![],
254                },
255                package: Package {
256                    id: format!("bootstrap:{digest}"),
257                    target: target.into(),
258                    sha256: digest,
259                    size: archive.bytes.len() as u64,
260                    command: manifest.command,
261                },
262                download_path: String::new(),
263                signature: None,
264                author_signature: None,
265                install_script: None,
266                withdrawn: vec![],
267            };
268            (resolution, archive.bytes)
269        }
270    };
271    ensure!(
272        resolution.app_id == spec.app_id && resolution.release.app_id == spec.app_id,
273        "server returned a release for a different app"
274    );
275    ensure!(
276        resolution.release.channel == spec.channel,
277        "server returned a different release channel"
278    );
279    ensure!(
280        resolution.package.target == target,
281        "server returned a package for the wrong target"
282    );
283    if let Some(version) = &spec.version {
284        ensure!(
285            &resolution.release.version == version,
286            "server returned a different exact version"
287        );
288    }
289    let manifest = package::inspect_archive(&bytes)?;
290    ensure!(
291        manifest.app_id == spec.app_id || legacy_manifest(&manifest, spec, &source_server),
292        "manifest belongs to another app"
293    );
294    // A registry package is installed only when its signature checks out. A
295    // local archive is trusted through the --sha256 its caller supplied.
296    let verified = match client {
297        Some(client) => Some(
298            crate::signing::verify_package(client, state, &resolution, &bytes, &manifest, target)
299                .await?,
300        ),
301        None => None,
302    };
303    let script_digest = match &verified {
304        Some(v) => v.install_script_sha256.clone(),
305        None => crate::signing::install_script_sha256(&bytes, &manifest, target)?,
306    };
307    let package_target = manifest
308        .targets
309        .get(target)
310        .context("package manifest does not support this target")?;
311    ensure!(
312        manifest.command == resolution.package.command,
313        "manifest command differs from release metadata"
314    );
315    let destination = state.root.join("installed").join(&spec.app_id);
316    let previous_destination = state.root.join("installed").join(previous_id);
317    ensure!(
318        !legacy || !destination.exists(),
319        "Both old and new Silicon Apps directories exist; preserve them and resolve the conflict before upgrading"
320    );
321    let command_path = state
322        .root
323        .join("bin")
324        .join(command_filename(&manifest.command));
325    ensure!(
326        installed
327            .values()
328            .all(|i| i.app_id == previous_id || i.command != manifest.command),
329        "command `{}` belongs to another installed app",
330        manifest.command
331    );
332    if command_path.exists() || fs::symlink_metadata(&command_path).is_ok() {
333        ensure!(
334            installed
335                .get(previous_id)
336                .is_some_and(|i| i.command == manifest.command),
337            "{} already exists and is not owned by this app; refusing to overwrite it",
338            command_path.display()
339        );
340    }
341    let staging = tempfile::tempdir_in(state.root.join("installed"))?;
342    package::extract_archive(&bytes, staging.path())?;
343    #[cfg(unix)]
344    {
345        use std::os::unix::fs::PermissionsExt;
346        fs::set_permissions(
347            staging.path().join(&package_target.binary),
348            fs::Permissions::from_mode(0o755),
349        )?;
350    }
351    let old = installed.get(previous_id).cloned();
352    let backup = state.root.join("installed").join(format!(
353        ".{}-backup-{}",
354        spec.app_id,
355        uuid::Uuid::new_v4()
356    ));
357    if previous_destination.exists() {
358        rename_installed(&previous_destination, &backup).await?;
359    }
360    if let Err(e) = fs::rename(staging.path(), &destination) {
361        if backup.exists() {
362            let _ = fs::rename(&backup, &previous_destination);
363        }
364        return Err(e.into());
365    }
366    let event_id = uuid::Uuid::new_v4().to_string();
367    let event_path = state
368        .root
369        .join("install-events")
370        .join(format!("{event_id}.json"));
371    let event = client.map(|client| InstallEvent {
372        app_id: spec.app_id.clone(),
373        release_id: resolution.release.id.clone(),
374        package_id: resolution.package.id.clone(),
375        idempotency_key: event_id,
376        server: client.base_url().to_owned(),
377    });
378    let transaction = async {
379        link_command(&destination.join(&package_target.binary), &command_path)?;
380        if let Some(script) = &package_target.install_script {
381            run_script(&destination, script, config.install_script_timeout_seconds).await?;
382        }
383        let item = Installed {
384            app_id: spec.app_id.clone(),
385            channel: spec.channel.clone(),
386            version: resolution.release.version.clone(),
387            target: target.into(),
388            command: manifest.command.clone(),
389            release_id: resolution.release.id.clone(),
390            package_id: resolution.package.id.clone(),
391            sha256: resolution.package.sha256.clone(),
392            installed_at: chrono::Utc::now().to_rfc3339(),
393            server: source_server.clone(),
394            signature_key_id: verified.as_ref().map(|v| v.key_id.clone()),
395            author_key_id: verified.as_ref().and_then(|v| v.author_key_id.clone()),
396            signed_by: verified.as_ref().and_then(|v| v.author.clone()),
397            install_script: Some(script_digest.clone().unwrap_or_else(|| "none".into())),
398        };
399        if legacy {
400            installed.remove("apps");
401        }
402        installed.insert(spec.app_id.clone(), item.clone());
403        if let Some(event) = &event {
404            persistence::atomic_json(&event_path, event)?;
405        }
406        state.save_installed(&installed)?;
407        Ok::<_, anyhow::Error>(item)
408    }
409    .await;
410    let item = match transaction {
411        Ok(i) => i,
412        Err(error) => {
413            let _ = fs::remove_file(&event_path);
414            let _ = remove_command(&command_path);
415            let _ = fs::remove_dir_all(&destination);
416            if backup.exists() {
417                fs::rename(&backup, &previous_destination)
418                    .context("rollback failed; previous files remain in backup")?;
419            }
420            if let Some(old) = &old {
421                let old_manifest = package::validate_directory(&previous_destination)
422                    .manifest
423                    .context("previous manifest missing during rollback")?;
424                let old_binary = &old_manifest
425                    .targets
426                    .get(&old.target)
427                    .context("previous target missing")?
428                    .binary;
429                link_command(
430                    &previous_destination.join(old_binary),
431                    &state.root.join("bin").join(command_filename(&old.command)),
432                )?;
433            }
434            bail!("install failed and previous version was restored: {error:#}")
435        }
436    };
437    if backup.exists() {
438        fs::remove_dir_all(&backup)?;
439    }
440    let notice = old.as_ref().and_then(|old| change_notice(old, &item));
441    if let Some(old) = old
442        && old.command != item.command
443    {
444        remove_command(&state.root.join("bin").join(command_filename(&old.command)))?;
445    }
446    let count = match (client, event.as_ref()) {
447        (Some(client), Some(event)) => record_install(client, event).await,
448        _ => Ok(()),
449    };
450    if count.is_ok() {
451        let _ = fs::remove_file(&event_path);
452    }
453    Ok(InstallOutcome {
454        message: format!(
455            "Installed {} {} ({}). Run `{} --help`. Add {} to PATH if needed.",
456            item.app_id,
457            item.version,
458            item.channel,
459            item.command,
460            state.root.join("bin").display()
461        ),
462        installed: item,
463        count_recorded: client.is_some() && count.is_ok(),
464        notice,
465        warning: if client.is_none() {
466            Some("Local archive registered for automatic channel updates; registry install count will be recorded on its first registry installation.".into())
467        } else {
468            count.err().map(|e| {
469            format!("Installed successfully; install count is queued for an idempotent retry by the updater: {e:#}")
470        })
471        },
472    })
473}
474
475/// One line about what changed between two installed versions that the
476/// person should know: a different install script, or a different (or
477/// missing) author signature.
478pub fn change_notice(old: &Installed, new: &Installed) -> Option<String> {
479    let short = |digest: &str| -> String {
480        if digest == "none" {
481            "none".into()
482        } else {
483            digest.chars().take(12).collect()
484        }
485    };
486    if let (Some(before), Some(after)) = (&old.install_script, &new.install_script)
487        && before != after
488    {
489        return Some(if after == "none" {
490            format!(
491                "{} {} no longer runs an install script (it was sha256 {}).",
492                new.app_id,
493                new.version,
494                short(before)
495            )
496        } else {
497            format!(
498                "{} {} changes its install script (sha256 {} -> {}). Read it with `silicon-apps show {} --install-script`.",
499                new.app_id,
500                new.version,
501                short(before),
502                short(after),
503                new.app_id
504            )
505        });
506    }
507    match (&old.author_key_id, &new.author_key_id) {
508        (Some(before), None) => Some(format!(
509            "{} {} is not signed by an author, though {} was (key {before}).",
510            new.app_id, new.version, old.version
511        )),
512        (Some(before), Some(after)) if before != after => Some(format!(
513            "{} {} is signed by a different author key ({before} -> {after}, {}).",
514            new.app_id,
515            new.version,
516            new.signed_by.as_deref().unwrap_or("unknown signer")
517        )),
518        _ => None,
519    }
520}
521
522/// Read the install script a release runs on a target, after checking the
523/// package's signatures, without installing anything.
524pub async fn inspect_install_script(
525    client: &Client,
526    state: &LocalState,
527    spec: &InstallSpec,
528    target: &str,
529) -> Result<serde_json::Value> {
530    let resolution = client.resolve(spec, target).await?;
531    let bytes = client.download(&resolution).await?;
532    let manifest = package::inspect_archive(&bytes)?;
533    let verified =
534        crate::signing::verify_package(client, state, &resolution, &bytes, &manifest, target)
535            .await?;
536    let script = package::install_script(&bytes, &manifest, target)?;
537    let label = format!(
538        "{} {} ({}, {target})",
539        resolution.app_id, resolution.release.version, resolution.release.channel
540    );
541    let mut value = json!({
542        "app_id":resolution.app_id,
543        "version":resolution.release.version,
544        "channel":resolution.release.channel,
545        "release_id":resolution.release.id,
546        "target":target,
547        "signature_key_id":verified.key_id,
548        "signed_by":verified.author,
549        "install_script":null,
550    });
551    match script {
552        None => value["message"] = json!(format!("{label} has no install script.")),
553        Some((info, content)) => {
554            let text = String::from_utf8(content).ok();
555            value["install_script"] = json!({
556                "path":info.path,
557                "sha256":info.sha256,
558                "size":info.size,
559                "content":text,
560                "binary":text.is_none(),
561            });
562            value["message"] = json!(format!(
563                "{label} runs {} on install and update.\nsha256 {}\n\n{}",
564                info.path,
565                info.sha256,
566                text.as_deref()
567                    .unwrap_or("(not UTF-8 text; use --json to see its digest and size)")
568            ));
569        }
570    }
571    Ok(value)
572}
573
574// Historical first-party archives are immutable. Their checksum still comes
575// from the trusted catalog or the explicit local --sha256 argument.
576fn legacy_manifest(manifest: &package::Manifest, spec: &InstallSpec, server: &str) -> bool {
577    spec.app_id == crate::APP_ID
578        && server == crate::DEFAULT_URL
579        && manifest.app_id == "apps"
580        && matches!(manifest.command.as_str(), "apps" | "silicon-apps")
581        && matches!(
582            manifest.version.as_str(),
583            "0.1.0" | "0.1.1" | "0.1.2" | "0.1.3" | "0.1.4" | "0.1.5" | "0.1.6" | "0.1.7" | "0.1.8"
584        )
585}
586#[derive(Serialize, Deserialize)]
587struct InstallEvent {
588    app_id: String,
589    release_id: String,
590    package_id: String,
591    idempotency_key: String,
592    server: String,
593}
594async fn record_install(client: &Client, event: &InstallEvent) -> Result<()> {
595    client
596        .action(
597            "POST",
598            &event.app_id,
599            &["installs"],
600            Some(json!({"release_id":event.release_id,"package_id":event.package_id})),
601            Some(&event.idempotency_key),
602        )
603        .await?;
604    Ok(())
605}
606
607pub(crate) async fn flush_install_events(client: &Client, state: &LocalState) -> Result<()> {
608    let _lock = state.lock("install-events")?;
609    let path = state.root.join("install-events");
610    if !path.is_dir() {
611        return Ok(());
612    }
613    for entry in fs::read_dir(path)? {
614        let entry = entry?;
615        if entry.path().extension().and_then(|s| s.to_str()) != Some("json") {
616            continue;
617        }
618        let event: InstallEvent = serde_json::from_slice(&fs::read(entry.path())?)?;
619        if event.server != client.base_url() {
620            continue;
621        }
622        if record_install(client, &event).await.is_ok() {
623            fs::remove_file(entry.path())?;
624        }
625    }
626    Ok(())
627}
628
629async fn rename_installed(source: &Path, destination: &Path) -> Result<()> {
630    #[cfg(windows)]
631    {
632        for attempt in 0..30 {
633            match fs::rename(source, destination) {
634                Ok(()) => return Ok(()),
635                Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied && attempt < 29 => {
636                    tokio::time::sleep(Duration::from_millis(100)).await
637                }
638                Err(e) => return Err(e.into()),
639            }
640        }
641        unreachable!()
642    }
643    #[cfg(not(windows))]
644    {
645        fs::rename(source, destination)?;
646        Ok(())
647    }
648}
649
650pub fn uninstall(state: &LocalState, id: &str) -> Result<String> {
651    ensure!(package::valid_existing_app_id(id), "invalid app_id `{id}`");
652    let _lock = state.lock("install")?;
653    let mut items = state.installed()?;
654    let item = items
655        .remove(id)
656        .with_context(|| format!("{id} is not installed"))?;
657    let destination = state.root.join("installed").join(id);
658    let trash = state
659        .root
660        .join("installed")
661        .join(format!(".uninstall-{id}-{}", uuid::Uuid::new_v4()));
662    if destination.exists() {
663        #[cfg(windows)]
664        {
665            let mut result = fs::rename(&destination, &trash);
666            for _ in 0..30 {
667                if !result
668                    .as_ref()
669                    .is_err_and(|e| e.kind() == std::io::ErrorKind::PermissionDenied)
670                {
671                    break;
672                }
673                std::thread::sleep(Duration::from_millis(100));
674                result = fs::rename(&destination, &trash);
675            }
676            result?;
677        }
678        #[cfg(not(windows))]
679        fs::rename(&destination, &trash)?;
680    }
681    if let Err(error) = state.save_installed(&items) {
682        if trash.exists() {
683            let _ = fs::rename(&trash, &destination);
684        }
685        return Err(error);
686    }
687    remove_command(&state.root.join("bin").join(command_filename(&item.command)))?;
688    if trash.exists() {
689        fs::remove_dir_all(trash)?;
690    }
691    Ok(format!(
692        "Uninstalled {id}. You can leave a review with `apps review {id} --rating 5`."
693    ))
694}
695fn command_filename(command: &str) -> String {
696    if cfg!(windows) {
697        format!("{command}.cmd")
698    } else {
699        command.to_owned()
700    }
701}
702fn remove_command(path: &Path) -> Result<()> {
703    match fs::remove_file(path) {
704        Ok(()) => Ok(()),
705        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
706        Err(e) => Err(e.into()),
707    }
708}
709fn link_command(binary: &Path, command: &Path) -> Result<()> {
710    let temp = command.with_extension(format!("tmp-{}", uuid::Uuid::new_v4()));
711    #[cfg(unix)]
712    std::os::unix::fs::symlink(binary, &temp)?;
713    #[cfg(windows)]
714    {
715        ensure!(
716            !binary.to_string_lossy().contains(['\r', '\n', '"', '%']),
717            "binary path cannot be represented safely in a Windows command shim"
718        );
719        fs::write(
720            &temp,
721            format!(
722                "@echo off\r\n\"{}\" %*\r\n",
723                crate::state::windows_shell_path(binary)
724            ),
725        )?;
726    }
727    #[cfg(not(any(unix, windows)))]
728    fs::copy(binary, &temp)?;
729    #[cfg(windows)]
730    remove_command(command)?;
731    fs::rename(temp, command)?;
732    Ok(())
733}
734async fn run_script(root: &Path, script: &str, timeout: u64) -> Result<()> {
735    use std::process::Stdio;
736    let path = root.join(script);
737    let output = tempfile::tempfile()?;
738    #[cfg(unix)]
739    let mut command = {
740        let mut c = tokio::process::Command::new("/bin/sh");
741        c.arg(&path).process_group(0);
742        c
743    };
744    #[cfg(windows)]
745    let mut command = {
746        // cmd.exe cannot execute the extended-length absolute paths produced by
747        // canonicalize. Run the manifest path relative to the install directory.
748        // Expand it once inside quotes, with delayed expansion disabled.
749        let mut c = tokio::process::Command::new("cmd.exe");
750        c.env(
751            "APPS_INSTALL_SCRIPT",
752            format!(".\\{}", script.replace('/', "\\")),
753        )
754        .args(["/D", "/V:OFF", "/S", "/C"])
755        .raw_arg(r#"""%APPS_INSTALL_SCRIPT%"""#);
756        c
757    };
758    command
759        .current_dir(root)
760        .env("APPS_INSTALL_DIR", root)
761        .stdin(Stdio::null())
762        .stdout(output.try_clone()?)
763        .stderr(output.try_clone()?)
764        .kill_on_drop(true);
765    let mut child = command
766        .spawn()
767        .with_context(|| format!("cannot start install script {}", path.display()))?;
768    let status =
769        tokio::time::timeout(Duration::from_secs(timeout.clamp(1, 3600)), child.wait()).await;
770    match status {
771        Ok(status) => {
772            let status = status?;
773            ensure!(
774                status.success(),
775                "install script `{script}` failed with {status}; previous version will be restored\n{}",
776                script_output_tail(&output)?
777            );
778        }
779        Err(_) => {
780            #[cfg(unix)]
781            if let Some(pid) = child.id() {
782                let _ = tokio::process::Command::new("/bin/kill")
783                    .args(["-KILL", "--", &format!("-{pid}")])
784                    .status()
785                    .await;
786            }
787            #[cfg(windows)]
788            if let Some(pid) = child.id() {
789                let _ = tokio::process::Command::new("taskkill.exe")
790                    .args(["/PID", &pid.to_string(), "/T", "/F"])
791                    .status()
792                    .await;
793            }
794            let _ = child.kill().await;
795            bail!(
796                "install script `{script}` exceeded {timeout} seconds; it was terminated and previous version will be restored\n{}",
797                script_output_tail(&output)?
798            );
799        }
800    }
801    Ok(())
802}
803
804fn script_output_tail(file: &fs::File) -> Result<String> {
805    use std::io::{Read, Seek, SeekFrom};
806    let mut file = file.try_clone()?;
807    let length = file.metadata()?.len();
808    file.seek(SeekFrom::Start(length.saturating_sub(64 * 1024)))?;
809    let mut bytes = Vec::new();
810    file.take(64 * 1024).read_to_end(&mut bytes)?;
811    if bytes.is_empty() {
812        Ok("The script produced no output.".into())
813    } else {
814        Ok(format!(
815            "Script output (last 64 KiB):\n{}",
816            String::from_utf8_lossy(&bytes)
817        ))
818    }
819}
820
821#[cfg(test)]
822mod tests {
823    use super::*;
824    #[cfg(windows)]
825    #[test]
826    fn windows_installed_command_runs_from_canonical_home_with_spaces() {
827        use std::{os::windows::process::CommandExt, path::PathBuf};
828        let temp = tempfile::tempdir().unwrap();
829        let home = temp.path().join("home with spaces");
830        fs::create_dir(&home).unwrap();
831        let state = LocalState::new(&home).unwrap();
832        state.initialize().unwrap();
833        let binary = state.root.join("installed/fixture.exe");
834        let shell = PathBuf::from(std::env::var_os("SystemRoot").unwrap())
835            .join("System32")
836            .join("cmd.exe");
837        fs::copy(std::env::current_exe().unwrap(), &binary).unwrap();
838        let command = state.root.join("bin/fixture.cmd");
839        link_command(&binary, &command).unwrap();
840        let result = std::process::Command::new(&shell)
841            .args(["/D", "/S", "/C"])
842            .raw_arg(format!(
843                "\"\"{}\" --exact install::tests::parses_channels_and_exact_versions --nocapture\"",
844                crate::state::windows_shell_path(&command)
845            ))
846            .output()
847            .unwrap();
848        assert!(result.status.success(), "{:?}", result);
849        assert!(String::from_utf8_lossy(&result.stdout).contains("1 passed"));
850    }
851    #[test]
852    fn parses_channels_and_exact_versions() {
853        for (input, channel, version) in [
854            ("ring", "production", None),
855            ("ring>dev", "development", None),
856            ("ring@1.2.3", "production", Some("1.2.3")),
857            ("ring>dev@1.2.3", "development", Some("1.2.3")),
858        ] {
859            let s: InstallSpec = input.parse().unwrap();
860            assert_eq!(s.channel, channel);
861            assert_eq!(s.version.as_deref(), version);
862            assert_eq!(s.to_string(), input);
863        }
864        for bad in [
865            "../oops",
866            "Ring",
867            "ring>prod",
868            "ring@latest",
869            "ring@1.2.3@4",
870            "ring@1.2.3-beta",
871        ] {
872            assert!(bad.parse::<InstallSpec>().is_err(), "{bad}");
873        }
874    }
875    #[test]
876    fn historical_short_app_ids_install_and_uninstall_like_any_other() {
877        for (input, id, channel, version) in [
878            ("dm", "dm", "production", None),
879            ("dm>dev", "dm", "development", None),
880            ("dm@1.2.3", "dm", "production", Some("1.2.3")),
881            ("x>dev@0.1.0", "x", "development", Some("0.1.0")),
882        ] {
883            let s: InstallSpec = input.parse().unwrap();
884            assert_eq!(
885                (s.app_id.as_str(), s.channel.as_str()),
886                (id, channel),
887                "{input}"
888            );
889            assert_eq!(s.version.as_deref(), version);
890            assert_eq!(s.to_string(), input);
891        }
892        let tmp = tempfile::tempdir().unwrap();
893        let state = LocalState::new(tmp.path()).unwrap();
894        // The ID is accepted; there is just nothing installed under it.
895        assert_eq!(
896            uninstall(&state, "dm").unwrap_err().to_string(),
897            "dm is not installed"
898        );
899    }
900    #[test]
901    fn home_must_be_a_directory() {
902        let tmp = tempfile::tempdir().unwrap();
903        let file = tmp.path().join("file");
904        fs::write(&file, "x").unwrap();
905        assert!(
906            LocalState::new(file)
907                .unwrap_err()
908                .to_string()
909                .contains("not a directory")
910        );
911    }
912}