1use 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 #[serde(default)]
81 pub signature_key_id: Option<String>,
82 #[serde(default)]
84 pub author_key_id: Option<String>,
85 #[serde(default)]
87 pub signed_by: Option<String>,
88 #[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 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
156pub 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 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 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
475pub 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
522pub 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
574fn 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 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 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}