1use std::fmt;
2use std::fs::{self, File, OpenOptions};
3use std::io;
4use std::path::{Component, Path, PathBuf};
5use std::sync::{Mutex, OnceLock};
6use std::time::Duration;
7
8use serde::{Deserialize, Serialize};
9use sha2::{Digest, Sha256};
10
11#[cfg(test)]
26pub(crate) mod probe_counter {
27 use std::cell::Cell;
28
29 thread_local! {
30 static ACQUIRE_CALLS: Cell<usize> = const { Cell::new(0) };
31 static ROOT_WALK_CALLS: Cell<usize> = const { Cell::new(0) };
32 }
33
34 pub(crate) fn record_acquire() {
35 ACQUIRE_CALLS.with(|calls| calls.set(calls.get() + 1));
36 }
37
38 pub(crate) fn record_root_walk() {
39 ROOT_WALK_CALLS.with(|calls| calls.set(calls.get() + 1));
40 }
41
42 pub(crate) fn count_probes<T>(body: impl FnOnce() -> T) -> (T, usize) {
46 let (value, walks, _) = count_walks_and_acquires(body);
47 (value, walks)
48 }
49
50 pub(crate) fn count_walks_and_acquires<T>(body: impl FnOnce() -> T) -> (T, usize, usize) {
51 ROOT_WALK_CALLS.with(|calls| calls.set(0));
52 ACQUIRE_CALLS.with(|calls| calls.set(0));
53 let value = body();
54 (
55 value,
56 ROOT_WALK_CALLS.with(|calls| calls.get()),
57 ACQUIRE_CALLS.with(|calls| calls.get()),
58 )
59 }
60}
61
62pub const PACKAGE_STATE_DIR: &str = ".harn";
63pub const PACKAGE_CURRENT_FILE: &str = "package-current.toml";
64pub const PACKAGE_GENERATIONS_DIR: &str = "package-generations";
65pub const PACKAGE_PUBLICATION_LOCK_FILE: &str = "package-generation.lock";
66pub const PACKAGE_INSTALL_LOCK_FILE: &str = "package-install.lock";
67pub const PACKAGE_MANIFEST_LOCK_FILE: &str = "package-manifest.lock";
68pub const PROJECT_MUTATION_LOCK_FILE: &str = "project-mutation.lock";
69pub const PERSONA_ACTIVATION_DIR: &str = ".harn/personas";
70pub const PERSONA_ACTIVATION_FILE: &str = "activations.json";
71pub const PERSONA_ACTIVATION_LOCK_FILE: &str = "activations.lock";
72pub const LEGACY_PACKAGES_DIR: &str = "packages";
73pub const GENERATION_MANIFEST_FILE: &str = "generation.toml";
74pub const GENERATION_LOCK_FILE: &str = "harn.lock";
75pub const GENERATION_LEASE_FILE: &str = "lease.lock";
76pub const GENERATION_PACKAGES_DIR: &str = "packages";
77pub const PACKAGE_GENERATION_SCHEMA_VERSION: u32 = 1;
78
79const PACKAGE_PUBLICATION_LOCK_TIMEOUT: Duration = Duration::from_mins(5);
83const PACKAGE_LEASE_LOCK_TIMEOUT: Duration = Duration::from_secs(30);
84
85#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
86#[serde(deny_unknown_fields)]
87pub struct PackageGenerationPointer {
88 pub schema_version: u32,
89 pub generation: String,
90}
91
92impl PackageGenerationPointer {
93 pub fn new(generation: impl Into<String>) -> Result<Self, PackageSnapshotError> {
94 let generation = generation.into();
95 validate_generation_id(&generation)?;
96 Ok(Self {
97 schema_version: PACKAGE_GENERATION_SCHEMA_VERSION,
98 generation,
99 })
100 }
101
102 pub fn validate(&self, path: &Path) -> Result<(), PackageSnapshotError> {
103 validate_schema_version(self.schema_version, path)?;
104 validate_generation_id(&self.generation)
105 }
106}
107
108#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
109#[serde(deny_unknown_fields)]
110pub struct PackageGenerationManifest {
111 pub schema_version: u32,
112 pub generation: String,
113 pub lock_digest: String,
114}
115
116impl PackageGenerationManifest {
117 pub fn new(
118 generation: impl Into<String>,
119 lock_digest: impl Into<String>,
120 ) -> Result<Self, PackageSnapshotError> {
121 let generation = generation.into();
122 validate_generation_id(&generation)?;
123 let lock_digest = lock_digest.into();
124 validate_lock_digest(&lock_digest)?;
125 Ok(Self {
126 schema_version: PACKAGE_GENERATION_SCHEMA_VERSION,
127 generation,
128 lock_digest,
129 })
130 }
131
132 pub fn validate(&self, path: &Path) -> Result<(), PackageSnapshotError> {
133 validate_schema_version(self.schema_version, path)?;
134 validate_generation_id(&self.generation)?;
135 validate_lock_digest(&self.lock_digest)
136 }
137}
138
139#[derive(Debug)]
140pub struct PackageSnapshot {
141 project_root: PathBuf,
142 generation: String,
143 generation_root: PathBuf,
144 packages_root: PathBuf,
145 lock_path: PathBuf,
146 lock_digest: String,
147 package_names: Vec<String>,
148 _lease: File,
149}
150
151static PROCESS_PACKAGE_SNAPSHOTS: OnceLock<Mutex<Vec<PackageSnapshot>>> = OnceLock::new();
159
160impl PackageSnapshot {
161 pub(crate) fn retain_for_process(self) {
166 let snapshots = PROCESS_PACKAGE_SNAPSHOTS.get_or_init(|| Mutex::new(Vec::new()));
167 let mut snapshots = snapshots
168 .lock()
169 .unwrap_or_else(std::sync::PoisonError::into_inner);
170 if snapshots.iter().any(|snapshot| {
171 snapshot.project_root == self.project_root && snapshot.generation == self.generation
172 }) {
173 return;
174 }
175 snapshots.push(self);
176 }
177
178 pub fn retained_clone(&self) -> Result<Self, PackageSnapshotError> {
180 Ok(Self {
181 project_root: self.project_root.clone(),
182 generation: self.generation.clone(),
183 generation_root: self.generation_root.clone(),
184 packages_root: self.packages_root.clone(),
185 lock_path: self.lock_path.clone(),
186 lock_digest: self.lock_digest.clone(),
187 package_names: self.package_names.clone(),
188 _lease: self._lease.try_clone().map_err(|error| {
189 PackageSnapshotError::io("clone lease", &self.generation_root, error)
190 })?,
191 })
192 }
193
194 pub fn acquire(project_root: &Path) -> Result<Option<Self>, PackageSnapshotError> {
199 #[cfg(test)]
200 probe_counter::record_acquire();
201 let project_root = project_root
202 .canonicalize()
203 .map_err(|error| PackageSnapshotError::io("canonicalize", project_root, error))?;
204 let state_path = project_root.join(PACKAGE_STATE_DIR);
205 if !state_path.is_dir() {
206 return Ok(None);
207 }
208 let state_dir = canonical_directory_within(&project_root, &state_path)?;
209 let pointer_path = state_dir.join(PACKAGE_CURRENT_FILE);
210 let publication_lock_path = state_dir.join(PACKAGE_PUBLICATION_LOCK_FILE);
211 if !publication_lock_path.exists() && !pointer_path.exists() {
212 return Ok(None);
213 }
214 require_regular_file(&publication_lock_path)?;
215 let publication_lock = open_existing_lock_file(&publication_lock_path)?;
216 harn_flock::lock_with_deadline(
217 &publication_lock,
218 &publication_lock_path,
219 harn_flock::LockMode::Shared,
220 PACKAGE_PUBLICATION_LOCK_TIMEOUT,
221 )
222 .map_err(|error| {
223 PackageSnapshotError::io("lock", &publication_lock_path, io::Error::other(error))
224 })?;
225
226 if !pointer_path.is_file() {
227 return Ok(None);
228 }
229 require_regular_file(&pointer_path)?;
230
231 let pointer = read_toml::<PackageGenerationPointer>(&pointer_path)?;
232 pointer.validate(&pointer_path)?;
233 let generations_dir =
234 canonical_directory_within(&state_dir, &state_dir.join(PACKAGE_GENERATIONS_DIR))?;
235 let generation_root = canonical_directory_within(
236 &generations_dir,
237 &generations_dir.join(&pointer.generation),
238 )?;
239 let lease_path = generation_root.join(GENERATION_LEASE_FILE);
240 require_regular_file(&lease_path)?;
241 let lease = open_existing_lock_file(&lease_path)?;
242 harn_flock::lock_with_deadline(
243 &lease,
244 &lease_path,
245 harn_flock::LockMode::Shared,
246 PACKAGE_LEASE_LOCK_TIMEOUT,
247 )
248 .map_err(|error| PackageSnapshotError::io("lock", &lease_path, io::Error::other(error)))?;
249
250 publication_lock
253 .unlock()
254 .map_err(|error| PackageSnapshotError::io("unlock", &publication_lock_path, error))?;
255
256 let manifest_path = generation_root.join(GENERATION_MANIFEST_FILE);
257 require_regular_file(&manifest_path)?;
258 let manifest = read_toml::<PackageGenerationManifest>(&manifest_path)?;
259 manifest.validate(&manifest_path)?;
260 if manifest.generation != pointer.generation {
261 return Err(PackageSnapshotError::Invalid(format!(
262 "{} names generation {:?}, expected {:?}",
263 manifest_path.display(),
264 manifest.generation,
265 pointer.generation
266 )));
267 }
268 let packages_root = canonical_directory_within(
269 &generation_root,
270 &generation_root.join(GENERATION_PACKAGES_DIR),
271 )?;
272 let lock_path = generation_root.join(GENERATION_LOCK_FILE);
273 require_regular_file(&lock_path)?;
274 let lock_bytes = fs::read(&lock_path)
275 .map_err(|error| PackageSnapshotError::io("read", &lock_path, error))?;
276 let actual_lock_digest = package_lock_digest(&lock_bytes);
277 if actual_lock_digest != manifest.lock_digest {
278 return Err(PackageSnapshotError::Invalid(format!(
279 "{} digest mismatch: generation manifest records {}, actual {}",
280 lock_path.display(),
281 manifest.lock_digest,
282 actual_lock_digest
283 )));
284 }
285 let package_names = parse_package_names(&lock_path, &lock_bytes)?;
286
287 Ok(Some(Self {
288 project_root,
289 generation: pointer.generation,
290 generation_root,
291 packages_root,
292 lock_path,
293 lock_digest: manifest.lock_digest,
294 package_names,
295 _lease: lease,
296 }))
297 }
298
299 pub fn nearest_project_root(anchor: &Path) -> Option<PathBuf> {
308 #[cfg(test)]
309 probe_counter::record_root_walk();
310 let sentinel = Path::new(PACKAGE_STATE_DIR).join(PACKAGE_CURRENT_FILE);
314 crate::manifest_walk::find_nearest_ancestor(anchor, sentinel).map(|found| found.dir)
315 }
316
317 pub fn acquire_nearest(anchor: &Path) -> Result<Option<Self>, PackageSnapshotError> {
318 match Self::nearest_project_root(anchor) {
319 Some(root) => Self::acquire(&root),
320 None => Ok(None),
321 }
322 }
323
324 pub fn project_root(&self) -> &Path {
325 &self.project_root
326 }
327
328 pub fn generation(&self) -> &str {
329 &self.generation
330 }
331
332 pub fn generation_root(&self) -> &Path {
333 &self.generation_root
334 }
335
336 pub fn packages_root(&self) -> &Path {
337 &self.packages_root
338 }
339
340 pub fn lock_path(&self) -> &Path {
341 &self.lock_path
342 }
343
344 pub fn lock_digest(&self) -> &str {
345 &self.lock_digest
346 }
347
348 pub fn package_names(&self) -> &[String] {
349 &self.package_names
350 }
351}
352
353#[derive(Debug)]
354#[non_exhaustive]
355pub enum PackageSnapshotError {
356 Io {
357 operation: &'static str,
358 path: PathBuf,
359 source: io::Error,
360 },
361 Invalid(String),
362}
363
364impl PackageSnapshotError {
365 fn io(operation: &'static str, path: &Path, source: io::Error) -> Self {
366 Self::Io {
367 operation,
368 path: path.to_path_buf(),
369 source,
370 }
371 }
372}
373
374impl fmt::Display for PackageSnapshotError {
375 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
376 match self {
377 Self::Io {
378 operation,
379 path,
380 source,
381 } => write!(
382 formatter,
383 "failed to {operation} {}: {source}",
384 path.display()
385 ),
386 Self::Invalid(message) => formatter.write_str(message),
387 }
388 }
389}
390
391impl std::error::Error for PackageSnapshotError {
392 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
393 match self {
394 Self::Io { source, .. } => Some(source),
395 Self::Invalid(_) => None,
396 }
397 }
398}
399
400pub fn package_state_dir(project_root: &Path) -> PathBuf {
401 project_root.join(PACKAGE_STATE_DIR)
402}
403
404pub fn package_generations_dir(project_root: &Path) -> PathBuf {
405 package_state_dir(project_root).join(PACKAGE_GENERATIONS_DIR)
406}
407
408pub fn package_publication_lock_path(project_root: &Path) -> PathBuf {
409 package_state_dir(project_root).join(PACKAGE_PUBLICATION_LOCK_FILE)
410}
411
412pub fn package_current_path(project_root: &Path) -> PathBuf {
413 package_state_dir(project_root).join(PACKAGE_CURRENT_FILE)
414}
415
416pub fn generation_root(project_root: &Path, generation: &str) -> PathBuf {
417 package_generations_dir(project_root).join(generation)
418}
419
420pub fn path_within_package_generation(path: &Path) -> Option<PathBuf> {
427 let components = path.components().collect::<Vec<_>>();
428 let layout = [PACKAGE_STATE_DIR, PACKAGE_GENERATIONS_DIR];
429 let start = components.windows(4).rposition(|window| {
430 window[0].as_os_str() == layout[0]
431 && window[1].as_os_str() == layout[1]
432 && matches!(window[2], Component::Normal(id)
433 if id.to_str().is_some_and(|id| validate_generation_id(id).is_ok()))
434 && window[3].as_os_str() == GENERATION_PACKAGES_DIR
435 })?;
436 let rest = &components[start + 4..];
437 if rest.is_empty() || !rest.iter().all(|c| matches!(c, Component::Normal(_))) {
438 return None;
439 }
440 Some(rest.iter().collect())
441}
442
443pub fn open_lock_file(path: &Path) -> Result<File, PackageSnapshotError> {
444 if let Some(parent) = path.parent() {
445 fs::create_dir_all(parent)
446 .map_err(|error| PackageSnapshotError::io("create", parent, error))?;
447 }
448 OpenOptions::new()
449 .read(true)
450 .write(true)
451 .create(true)
452 .truncate(false)
453 .open(path)
454 .map_err(|error| PackageSnapshotError::io("open", path, error))
455}
456
457fn open_existing_lock_file(path: &Path) -> Result<File, PackageSnapshotError> {
458 OpenOptions::new()
459 .read(true)
460 .write(true)
461 .open(path)
462 .map_err(|error| PackageSnapshotError::io("open", path, error))
463}
464
465fn read_toml<T>(path: &Path) -> Result<T, PackageSnapshotError>
466where
467 T: for<'de> Deserialize<'de>,
468{
469 let source =
470 fs::read_to_string(path).map_err(|error| PackageSnapshotError::io("read", path, error))?;
471 toml::from_str(&source).map_err(|error| {
472 PackageSnapshotError::Invalid(format!("failed to parse {}: {error}", path.display()))
473 })
474}
475
476fn validate_schema_version(version: u32, path: &Path) -> Result<(), PackageSnapshotError> {
477 if version == PACKAGE_GENERATION_SCHEMA_VERSION {
478 Ok(())
479 } else {
480 Err(PackageSnapshotError::Invalid(format!(
481 "unsupported {} schema version {} (expected {})",
482 path.display(),
483 version,
484 PACKAGE_GENERATION_SCHEMA_VERSION
485 )))
486 }
487}
488
489pub fn validate_generation_id(generation: &str) -> Result<(), PackageSnapshotError> {
490 let path = Path::new(generation);
491 let mut components = path.components();
492 let Some(Component::Normal(component)) = components.next() else {
493 return Err(invalid_generation_id(generation));
494 };
495 if components.next().is_some()
496 || component.to_str() != Some(generation)
497 || generation.starts_with('.')
498 || !generation
499 .bytes()
500 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
501 {
502 return Err(invalid_generation_id(generation));
503 }
504 Ok(())
505}
506
507fn invalid_generation_id(generation: &str) -> PackageSnapshotError {
508 PackageSnapshotError::Invalid(format!("invalid package generation id {generation:?}"))
509}
510
511fn validate_lock_digest(digest: &str) -> Result<(), PackageSnapshotError> {
512 let Some(hex) = digest.strip_prefix("sha256:") else {
513 return Err(PackageSnapshotError::Invalid(format!(
514 "invalid package lock digest {digest:?}"
515 )));
516 };
517 if hex.len() != 64 || !hex.bytes().all(|byte| byte.is_ascii_hexdigit()) {
518 return Err(PackageSnapshotError::Invalid(format!(
519 "invalid package lock digest {digest:?}"
520 )));
521 }
522 Ok(())
523}
524
525#[derive(Deserialize)]
526struct PublishedLock {
527 #[serde(default, rename = "package")]
528 packages: Vec<PublishedLockEntry>,
529}
530
531#[derive(Deserialize)]
532struct PublishedLockEntry {
533 name: String,
534}
535
536fn parse_package_names(path: &Path, bytes: &[u8]) -> Result<Vec<String>, PackageSnapshotError> {
537 let source = std::str::from_utf8(bytes).map_err(|error| {
538 PackageSnapshotError::Invalid(format!("{} is not UTF-8: {error}", path.display()))
539 })?;
540 let lock = toml::from_str::<PublishedLock>(source).map_err(|error| {
541 PackageSnapshotError::Invalid(format!("failed to parse {}: {error}", path.display()))
542 })?;
543 let mut names = std::collections::BTreeSet::new();
544 for entry in lock.packages {
545 if !is_valid_package_name(&entry.name) || !names.insert(entry.name.clone()) {
546 return Err(PackageSnapshotError::Invalid(format!(
547 "{} contains an invalid or duplicate package name {:?}",
548 path.display(),
549 entry.name
550 )));
551 }
552 }
553 Ok(names.into_iter().collect())
554}
555
556pub fn is_valid_package_name(name: &str) -> bool {
558 !name.is_empty()
559 && name != "."
560 && name != ".."
561 && name
562 .bytes()
563 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.'))
564}
565
566pub fn package_lock_digest(bytes: &[u8]) -> String {
567 format!("sha256:{}", hex::encode(Sha256::digest(bytes)))
568}
569
570fn canonical_directory_within(root: &Path, path: &Path) -> Result<PathBuf, PackageSnapshotError> {
571 let canonical = path
572 .canonicalize()
573 .map_err(|error| PackageSnapshotError::io("canonicalize", path, error))?;
574 if canonical == root || canonical.starts_with(root) {
575 Ok(canonical)
576 } else {
577 Err(PackageSnapshotError::Invalid(format!(
578 "package generation directory escapes {}: {}",
579 root.display(),
580 path.display()
581 )))
582 }
583}
584
585fn require_regular_file(path: &Path) -> Result<(), PackageSnapshotError> {
586 let metadata = fs::symlink_metadata(path)
587 .map_err(|error| PackageSnapshotError::io("stat", path, error))?;
588 if metadata.file_type().is_file() {
589 return Ok(());
590 }
591 Err(PackageSnapshotError::Invalid(format!(
592 "package generation file is not a regular file: {}",
593 path.display()
594 )))
595}
596
597#[cfg(test)]
598mod tests {
599 use super::*;
600 use std::sync::{Arc, Barrier};
601
602 fn publish_fixture(root: &Path, generation: &str, body: &str) {
603 let generation_root = generation_root(root, generation);
604 fs::create_dir_all(generation_root.join(GENERATION_PACKAGES_DIR)).unwrap();
605 fs::write(generation_root.join(GENERATION_LOCK_FILE), body).unwrap();
606 fs::write(generation_root.join(GENERATION_LEASE_FILE), []).unwrap();
607 let digest = package_lock_digest(body.as_bytes());
608 let manifest = PackageGenerationManifest::new(generation, digest).unwrap();
609 fs::write(
610 generation_root.join(GENERATION_MANIFEST_FILE),
611 toml::to_string_pretty(&manifest).unwrap(),
612 )
613 .unwrap();
614 let pointer = PackageGenerationPointer::new(generation).unwrap();
615 fs::create_dir_all(package_state_dir(root)).unwrap();
616 fs::write(
617 package_current_path(root),
618 toml::to_string_pretty(&pointer).unwrap(),
619 )
620 .unwrap();
621 File::create(package_publication_lock_path(root)).unwrap();
622 }
623
624 #[test]
625 fn snapshot_holds_generation_lease_until_drop() {
626 let temp = tempfile::tempdir().unwrap();
627 publish_fixture(temp.path(), "generation_a", "version = 4\n# lock a\n");
628
629 let snapshot = PackageSnapshot::acquire(temp.path()).unwrap().unwrap();
630 let lease =
631 open_existing_lock_file(&snapshot.generation_root().join(GENERATION_LEASE_FILE))
632 .unwrap();
633 assert!(lease.try_lock().is_err());
634
635 drop(snapshot);
636 harn_flock::assert_released(&lease);
637 }
638
639 #[test]
640 fn reader_selects_generation_published_before_publication_unlock() {
641 let temp = tempfile::tempdir().unwrap();
642 publish_fixture(temp.path(), "generation_a", "version = 4\n# lock a\n");
643 let root = temp.path().to_path_buf();
644 let publication = open_lock_file(&package_publication_lock_path(&root)).unwrap();
645 publication.lock().unwrap();
646
647 let started = Arc::new(Barrier::new(2));
648 let reader_started = Arc::clone(&started);
649 let reader_root = root.clone();
650 let reader = harn_parser::runtime_stack::spawn(move || {
651 reader_started.wait();
652 PackageSnapshot::acquire(&reader_root).unwrap().unwrap()
653 });
654 started.wait();
655
656 publish_fixture(&root, "generation_b", "version = 4\n# lock b\n");
657 publication.unlock().unwrap();
658
659 let snapshot = reader.join().unwrap();
660 assert_eq!(snapshot.generation(), "generation_b");
661 assert_eq!(
662 fs::read_to_string(snapshot.lock_path()).unwrap(),
663 "version = 4\n# lock b\n"
664 );
665 }
666
667 #[test]
668 fn malformed_pointer_cannot_escape_generation_root() {
669 let temp = tempfile::tempdir().unwrap();
670 fs::create_dir_all(package_state_dir(temp.path())).unwrap();
671 fs::write(
672 package_current_path(temp.path()),
673 "schema_version = 1\ngeneration = \"../outside\"\n",
674 )
675 .unwrap();
676 File::create(package_publication_lock_path(temp.path())).unwrap();
677
678 let error = PackageSnapshot::acquire(temp.path()).unwrap_err();
679 assert!(
680 error.to_string().contains("invalid package generation id"),
681 "unexpected error: {error}"
682 );
683 }
684
685 #[test]
686 fn lock_package_name_cannot_escape_packages_root() {
687 let temp = tempfile::tempdir().unwrap();
688 publish_fixture(
689 temp.path(),
690 "generation_a",
691 "version = 4\n\n[[package]]\nname = \"../outside\"\n",
692 );
693
694 let error = PackageSnapshot::acquire(temp.path()).unwrap_err();
695 assert!(
696 error
697 .to_string()
698 .contains("invalid or duplicate package name"),
699 "unexpected error: {error}"
700 );
701 }
702
703 #[cfg(unix)]
704 #[test]
705 fn symlinked_generation_root_cannot_escape_generations_directory() {
706 let temp = tempfile::tempdir().unwrap();
707 publish_fixture(temp.path(), "generation_a", "version = 4\n");
708 let generation = generation_root(temp.path(), "generation_a");
709 let outside = temp.path().join("outside-generation");
710 fs::rename(&generation, &outside).unwrap();
711 std::os::unix::fs::symlink(&outside, &generation).unwrap();
712
713 let error = PackageSnapshot::acquire(temp.path()).unwrap_err();
714 assert!(
715 error.to_string().contains("escapes"),
716 "unexpected error: {error}"
717 );
718 }
719}