1use std::collections::BTreeMap;
22use std::fmt::Write as _;
23
24use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd};
25
26use crate::engine::Engine;
27use crate::entity::Entity;
28use crate::workspace::MountCapability;
29
30fn esc(s: &str) -> String {
32 s.replace('&', "&")
33 .replace('<', "<")
34 .replace('>', ">")
35 .replace('"', """)
36}
37
38fn resolve_wiki_links(body: &str, mem: &str, exported_ids: &[String]) -> String {
44 let mut out = String::with_capacity(body.len());
45 let mut rest = body;
46 while let Some(start) = rest.find("[[") {
47 out.push_str(&rest[..start]);
48 let after = &rest[start + 2..];
49 match after.find("]]") {
50 None => {
51 out.push_str(&rest[start..]);
52 rest = "";
53 break;
54 }
55 Some(end) => {
56 let target = &after[..end];
57 let full_id = if target.contains("--") {
58 target.to_string()
59 } else {
60 format!("{mem}--{target}")
61 };
62 if exported_ids.iter().any(|id| id == &full_id) {
63 let _ = write!(out, "[{target}](#{full_id})");
66 } else if full_id.starts_with(&format!("{mem}--")) {
67 let _ = write!(out, "{target} *(unresolved)*");
69 } else {
70 let _ = write!(out, "{full_id} *(other mem)*");
72 }
73 rest = &after[end + 2..];
74 }
75 }
76 }
77 out.push_str(rest);
78 out
79}
80
81fn percent_decode(s: &str) -> String {
86 let bytes = s.as_bytes();
87 let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
88 let mut i = 0;
89 while i < bytes.len() {
90 if bytes[i] == b'%'
91 && i + 2 < bytes.len()
92 && let (Some(h), Some(l)) = (
93 (bytes[i + 1] as char).to_digit(16),
94 (bytes[i + 2] as char).to_digit(16),
95 )
96 {
97 out.push((h * 16 + l) as u8);
98 i += 3;
99 } else {
100 out.push(bytes[i]);
101 i += 1;
102 }
103 }
104 String::from_utf8_lossy(&out).into_owned()
105}
106
107fn link_dest_allowed(dest: &str, exported_ids: &[String]) -> bool {
115 if let Some(frag) = dest.strip_prefix('#') {
116 let decoded = percent_decode(frag);
117 return exported_ids.iter().any(|id| id == &decoded);
118 }
119 let lower = dest.trim().to_ascii_lowercase();
120 lower.starts_with("http://") || lower.starts_with("https://") || lower.starts_with("mailto:")
121}
122
123fn markdown_to_safe_html(md: &str, exported_ids: &[String]) -> String {
130 let parser = Parser::new_ext(md, Options::ENABLE_TABLES | Options::ENABLE_STRIKETHROUGH);
131 let mut events: Vec<Event> = Vec::new();
132 let mut skipping_image: Option<(String, String)> = None; let mut suppressed_link: Option<String> = None;
136 for ev in parser {
137 if let Some((_url, alt)) = skipping_image.as_mut() {
138 match ev {
139 Event::End(TagEnd::Image) => {
140 let (url, alt) = skipping_image.take().unwrap();
141 let label = if alt.trim().is_empty() {
142 format!("image: {url}")
143 } else {
144 format!("image: {alt} ({url})")
145 };
146 if link_dest_allowed(&url, exported_ids) {
147 events.push(Event::Start(Tag::Link {
148 link_type: pulldown_cmark::LinkType::Inline,
149 dest_url: url.clone().into(),
150 title: "".into(),
151 id: "".into(),
152 }));
153 events.push(Event::Text(label.into()));
154 events.push(Event::End(TagEnd::Link));
155 } else {
156 events.push(Event::Text(format!("[{label}]").into()));
158 }
159 }
160 Event::Text(t) => alt.push_str(&t),
161 _ => {}
162 }
163 continue;
164 }
165 match ev {
166 Event::Html(s) | Event::InlineHtml(s) => {
168 events.push(Event::Text(s));
169 }
170 Event::Start(Tag::Image { dest_url, .. }) => {
172 skipping_image = Some((dest_url.to_string(), String::new()));
173 }
174 Event::Start(Tag::Link { dest_url, .. }) if suppressed_link.is_none() => {
175 let dest = dest_url.to_string();
176 if link_dest_allowed(&dest, exported_ids) {
177 events.push(Event::Start(Tag::Link {
178 link_type: pulldown_cmark::LinkType::Inline,
179 dest_url: dest.into(),
180 title: "".into(),
181 id: "".into(),
182 }));
183 } else {
184 suppressed_link = Some(dest);
185 }
186 }
187 Event::End(TagEnd::Link) if suppressed_link.is_some() => {
188 let dest = suppressed_link.take().unwrap();
189 events.push(Event::Text(format!(" ({dest} — link removed)").into()));
190 }
191 other => events.push(other),
192 }
193 }
194 let mut html = String::new();
195 pulldown_cmark::html::push_html(&mut html, events.into_iter());
196 html
197}
198
199impl Engine {
200 pub fn render_html_export(
204 &self,
205 mem: &str,
206 export_date: &str,
207 ) -> Result<String, crate::engine::EngineError> {
208 let mounted = self
209 .mounts
210 .iter()
211 .find(|m| m.mount.mem == mem)
212 .ok_or_else(|| crate::engine::EngineError::UnknownMem(mem.to_string()))?;
213 let third_party = mounted.mount.capability == MountCapability::ReadOnly;
214 let config = self.mem_config_for(mem);
215 let schema = self.schemas.get(mem);
219 let schema_ref = self
220 .schemas
221 .get(mem)
222 .map(|s| {
223 let (n, v) = s.id();
224 format!("{n}@{v}")
225 })
226 .unwrap_or_else(|| "(unresolved)".to_string());
227
228 let mut entities: Vec<&Entity> = self
231 .store
232 .all_entities()
233 .filter(|e| e.mem == mem && !e.stub)
234 .collect();
235 entities.sort_by(|a, b| {
236 a.entity_type
237 .cmp(&b.entity_type)
238 .then_with(|| a.id.as_ref().cmp(b.id.as_ref()))
239 });
240 let mut stubs: Vec<&Entity> = self
241 .store
242 .all_entities()
243 .filter(|e| e.mem == mem && e.stub)
244 .collect();
245 stubs.sort_by(|a, b| a.id.as_ref().cmp(b.id.as_ref()));
246 let exported_ids: Vec<String> = entities.iter().map(|e| e.id.to_string()).collect();
247
248 let mut out = String::new();
249 out.push_str("<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n");
250 out.push_str("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n");
251 let doc_title = config
252 .and_then(|c| c.title.clone())
253 .unwrap_or_else(|| mem.to_string());
254 let _ = writeln!(out, "<title>{}</title>", esc(&doc_title));
255 out.push_str(
256 "<style>\n\
257 body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;\
258 max-width:52rem;margin:0 auto;padding:2rem 1rem;line-height:1.55;color:#1a1a1a;}\n\
259 h1{border-bottom:2px solid #ddd;padding-bottom:.3rem;}\n\
260 section.entity{border-top:1px solid #ddd;margin-top:2rem;padding-top:1rem;}\n\
261 table.meta{border-collapse:collapse;font-size:.9rem;margin:.5rem 0;}\n\
262 table.meta td{border:1px solid #ddd;padding:.15rem .5rem;}\n\
263 table.meta td:first-child{color:#555;}\n\
264 nav ul{columns:2;list-style:none;padding-left:0;}\n\
265 nav li{margin:.15rem 0;}\n\
266 .identity{background:#f6f6f6;border:1px solid #ddd;padding:.75rem 1rem;\
267 border-radius:4px;font-size:.95rem;}\n\
268 .badge{display:inline-block;background:#eee;border-radius:3px;\
269 padding:0 .4rem;font-size:.8rem;color:#555;}\n\
270 .stub{color:#888;font-style:italic;}\n\
271 .reltable{font-size:.9rem;}\n\
272 @media print{nav ul{columns:1;}}\n\
273 </style>\n</head>\n<body>\n",
274 );
275
276 let _ = write!(
278 out,
279 "<h1>{}</h1>\n<div class=\"identity\">\n",
280 esc(&doc_title)
281 );
282 let _ = writeln!(out, "<div><strong>Mem:</strong> {}</div>", esc(mem));
283 if let Some(desc) = config.and_then(|c| c.description.as_deref())
284 && !desc.is_empty()
285 {
286 let _ = writeln!(
287 out,
288 "<div><strong>Description:</strong> {}</div>",
289 esc(desc)
290 );
291 }
292 if let Some(subject) = config.and_then(|c| c.subject.as_ref()) {
293 let _ = writeln!(
294 out,
295 "<div><strong>Subject:</strong> {}</div>",
296 esc(&subject.scope)
297 );
298 }
299 let _ = writeln!(
300 out,
301 "<div><strong>Schema:</strong> {}</div>",
302 esc(&schema_ref)
303 );
304 let trust = if third_party {
305 "third-party (read-only mount — someone else's published content, quoted here)"
306 } else {
307 "first-party (writable mem of this workspace)"
308 };
309 let _ = writeln!(out, "<div><strong>Origin:</strong> {trust}</div>");
310 let _ = write!(
311 out,
312 "<div><strong>Exported:</strong> {} · {} entities</div>\n</div>\n",
313 esc(export_date),
314 entities.len()
315 );
316
317 let mut by_type: BTreeMap<&str, Vec<&Entity>> = BTreeMap::new();
319 for e in &entities {
320 by_type.entry(e.entity_type.as_str()).or_default().push(e);
321 }
322 out.push_str("<nav>\n<h2>Index</h2>\n");
323 for (ty, list) in &by_type {
324 let _ = write!(out, "<h3>{} ({})</h3>\n<ul>\n", esc(ty), list.len());
325 for e in list {
326 let _ = writeln!(
327 out,
328 "<li><a href=\"#{}\">{}</a></li>",
329 esc(e.id.as_ref()),
330 esc(&e.title)
331 );
332 }
333 out.push_str("</ul>\n");
334 }
335 out.push_str("</nav>\n");
336
337 for e in &entities {
339 let _ = write!(
340 out,
341 "<section class=\"entity\" id=\"{}\">\n<h2>{}</h2>\n<span class=\"badge\">{}</span> <span class=\"badge\">{}</span>\n",
342 esc(e.id.as_ref()),
343 esc(&e.title),
344 esc(&e.entity_type),
345 esc(e.id.as_ref()),
346 );
347 if !e.metadata.is_empty() {
348 out.push_str("<table class=\"meta\">\n");
349 for (k, v) in &e.metadata {
350 let _ = writeln!(
351 out,
352 "<tr><td>{}</td><td>{}</td></tr>",
353 esc(k),
354 esc(&v.to_frontmatter_string())
355 );
356 }
357 out.push_str("</table>\n");
358 }
359 for (key, body) in &e.sections {
360 if body.trim().is_empty() {
361 continue;
362 }
363 let heading = schema
376 .and_then(|s| s.get_type(&e.entity_type))
377 .and_then(|t| {
378 t.sections
379 .iter()
380 .find(|s| &s.key == key)
381 .map(|s| s.heading.clone())
382 })
383 .unwrap_or_else(|| key.clone());
384 let _ = writeln!(out, "<h3>{}</h3>", esc(&heading));
385 let resolved = resolve_wiki_links(body, mem, &exported_ids);
386 out.push_str(&markdown_to_safe_html(&resolved, &exported_ids));
387 }
388 if !e.relationships.is_empty() {
389 out.push_str("<h3>Relationships</h3>\n<ul class=\"reltable\">\n");
395 let mut rels = e.relationships.clone();
396 rels.sort_by(|a, b| {
397 a.rel_type
398 .cmp(&b.rel_type)
399 .then_with(|| a.target.as_ref().cmp(b.target.as_ref()))
400 });
401 for r in &rels {
402 let target_id = r.target.to_string();
403 let in_doc = exported_ids.iter().any(|id| id == &target_id);
404 let is_stub_target = self.store.get(&r.target).map(|t| t.stub).unwrap_or(false);
405 if in_doc {
406 let _ = writeln!(
407 out,
408 "<li>{} → <a href=\"#{}\">{}</a></li>",
409 esc(&r.rel_type),
410 esc(&target_id),
411 esc(&target_id)
412 );
413 } else if is_stub_target {
414 let _ = writeln!(
415 out,
416 "<li>{} → <span class=\"stub\">{} (stub — unresolved reference)</span></li>",
417 esc(&r.rel_type),
418 esc(&target_id)
419 );
420 } else {
421 let _ = writeln!(
422 out,
423 "<li>{} → {} <span class=\"badge\">other mem</span></li>",
424 esc(&r.rel_type),
425 esc(&target_id)
426 );
427 }
428 }
429 out.push_str("</ul>\n");
430 }
431 out.push_str("</section>\n");
432 }
433
434 if !stubs.is_empty() {
435 out.push_str(
436 "<section class=\"entity\">\n<h2>Unresolved references (stubs)</h2>\n<ul>\n",
437 );
438 for s in &stubs {
439 let _ = writeln!(
440 out,
441 "<li class=\"stub\">{} — referenced but never written</li>",
442 esc(s.id.as_ref())
443 );
444 }
445 out.push_str("</ul>\n</section>\n");
446 }
447
448 out.push_str("</body>\n</html>\n");
449 Ok(out)
450 }
451}
452
453#[cfg(test)]
454mod tests {
455 use super::*;
456 use tempfile::TempDir;
457
458 use crate::backend::MemBackend;
459 use crate::engine::test_helpers::{cli_actor, folder_mount};
460 use crate::storage::FilesystemMemWriter;
461 use crate::workspace::{Mount, MountCapability, MountLifecycle, MountStorage};
462
463 fn fixture_engine(tmp: &TempDir) -> Engine {
468 let mem_dir = tmp.path().to_path_buf();
469 std::fs::write(
470 mem_dir.join("bösenberg-söhne-rev-21.md"),
471 "---\ntype: spec\ncreated_date: 2026-01-01\nlast_modified: 2026-01-01\nlevel: M0\n---\n# Bösenberg & Söhne — Rev. 2.1\n\n## Identity\n\nCited in [[target-entity]] and [[über-ziele]] and cross-mem [[other--far-away]].\n\n<script>alert('x')</script>\n\n\n\nSee [docs](https://example.org/page), [broken](#bogus-frag), [evil](javascript:alert(2)).\n\n## Purpose\n\nZweck mit Umlauten: äöüß.\n",
472 )
473 .unwrap();
474 std::fs::write(
475 mem_dir.join("über-ziele.md"),
476 "---\ntype: spec\ncreated_date: 2026-01-01\nlast_modified: 2026-01-01\nlevel: M0\n---\n# Über-Ziele\n\n## Identity\n\nUmlaut-slug anchor target.\n\n## Purpose\n\nP.\n",
477 )
478 .unwrap();
479 std::fs::write(
480 mem_dir.join("target-entity.md"),
481 "---\ntype: spec\ncreated_date: 2026-01-01\nlast_modified: 2026-01-01\nlevel: M0\n---\n# Target Entity\n\n## Identity\n\nThe link target.\n\n## Purpose\n\nAnchors resolve here.\n",
482 )
483 .unwrap();
484 let writer = FilesystemMemWriter::new(mem_dir.clone());
485 Engine::from_mounts(vec![(
486 folder_mount("specs", mem_dir),
487 Box::new(writer) as Box<dyn MemBackend>,
488 )])
489 .unwrap()
490 }
491
492 fn assert_no_dangling_anchors(html: &str) {
495 let mut ids: Vec<&str> = Vec::new();
496 for part in html.split("id=\"").skip(1) {
497 if let Some(end) = part.find('"') {
498 ids.push(&part[..end]);
499 }
500 }
501 for part in html.split("href=\"#").skip(1) {
502 if let Some(end) = part.find('"') {
503 let anchor = percent_decode(&part[..end]);
504 assert!(
505 ids.iter().any(|id| *id == anchor),
506 "dangling in-document anchor #{anchor}"
507 );
508 }
509 }
510 }
511
512 #[test]
527 fn html_export_renders_the_declared_heading_not_the_section_key() {
528 let tmp = TempDir::new().unwrap();
529 let mem_dir = tmp.path().to_path_buf();
530 std::fs::write(
531 mem_dir.join("open-question.md"),
532 "---\ntype: inquiry\ncreated_date: 2026-01-01\nlast_modified: 2026-01-01\n\
533 status: open\nurgency: medium\n---\n# Open Question\n\n## Question\n\nQ?\n\n\
534 ## Significance\n\nS.\n\n## Current State\n\nWhere things stand.\n",
535 )
536 .unwrap();
537 let writer = FilesystemMemWriter::new(mem_dir.clone());
538 let engine = Engine::from_mounts(vec![(
539 folder_mount("specs", mem_dir),
540 Box::new(writer) as Box<dyn MemBackend>,
541 )])
542 .unwrap();
543
544 let html = engine.render_html_export("specs", "2026-08-15").unwrap();
545
546 assert!(
547 html.contains("<h3>Current State</h3>"),
548 "must render the declared heading; got:\n{html}"
549 );
550 assert!(
551 !html.contains("<h3>current_state</h3>"),
552 "must not render the storage key as a heading; got:\n{html}"
553 );
554 assert!(html.contains("<h3>Question</h3>"), "got:\n{html}");
556 assert!(html.contains("<h3>Significance</h3>"), "got:\n{html}");
557 assert_no_dangling_anchors(&html);
558
559 let tmp2 = TempDir::new().unwrap();
564 let goal_dir = tmp2.path().to_path_buf();
565 std::fs::write(
566 goal_dir.join("second-goal.md"),
567 "---\ntype: goal\ncreated_date: 2026-01-01\nlast_modified: 2026-01-01\n\
568 priority: high\nstatus: active\n---\n# Second Goal\n\n## Statement\n\nS.\n\n\
569 ## Rationale\n\nR.\n\n## Success Criteria\n\nC.\n\n## Out of Scope\n\n\
570 Everything else.\n",
571 )
572 .unwrap();
573 let goal_writer = FilesystemMemWriter::new(goal_dir.clone());
574 let goal_mount = Mount {
575 mem: "plans".to_string(),
576 schema: Some(memstead_schema::SchemaRef::new(
577 "planning",
578 semver::Version::new(0, 4, 0),
579 )),
580 storage: MountStorage::Folder { path: goal_dir },
581 capability: MountCapability::Write,
582 lifecycle: MountLifecycle::Eager,
583 cross_linkable: true,
584 migration_target: None,
585 };
586 let goal_engine = Engine::from_mounts(vec![(
587 goal_mount,
588 Box::new(goal_writer) as Box<dyn MemBackend>,
589 )])
590 .unwrap();
591 let goal_html = goal_engine
592 .render_html_export("plans", "2026-08-15")
593 .unwrap();
594 assert!(
595 goal_html.contains("<h3>Out of Scope</h3>"),
596 "the interior word must stay lowercase, as declared; got:\n{goal_html}"
597 );
598 assert!(
599 !goal_html.contains("<h3>Out Of Scope</h3>")
600 && !goal_html.contains("<h3>out_of_scope</h3>"),
601 "neither a title-cased guess nor the storage key; got:\n{goal_html}"
602 );
603
604 let again = engine.render_html_export("specs", "2026-08-15").unwrap();
606 assert_eq!(html, again, "export must be byte-deterministic");
607 }
608
609 #[test]
612 fn html_export_renders_sanitises_and_stays_self_contained() {
613 let tmp = TempDir::new().unwrap();
614 let mut engine = fixture_engine(&tmp);
615
616 let html = engine.render_html_export("specs", "2026-08-10").unwrap();
617
618 assert!(
620 html.contains("<strong>Mem:</strong> specs"),
621 "identity block"
622 );
623 assert!(html.contains("<strong>Exported:</strong> 2026-08-10"));
624 assert!(html.contains("<nav>"), "type-grouped index");
625 assert!(
626 html.contains("Bösenberg & Söhne — Rev. 2.1"),
627 "widened title escaped: {html}"
628 );
629 assert!(html.contains("äöüß"), "umlauts verbatim");
630
631 assert!(!html.contains("<script>"), "raw HTML must not pass through");
633 assert!(html.contains("<script>"), "escaped as visible text");
634
635 assert!(!html.contains("<img"), "no image element: {html}");
637 assert!(
638 html.contains("<a href=\"https://evil.example/x.png\">image: diagram (https://evil.example/x.png)</a>"),
639 "image degraded to labelled link: {html}"
640 );
641 assert!(html.contains("<a href=\"https://example.org/page\">docs</a>"));
642
643 assert!(
645 html.contains("href=\"#specs--target-entity\""),
646 "in-doc anchor"
647 );
648 assert!(html.contains("other--far-away"), "cross-mem labelled");
649 assert!(
650 !html.contains("href=\"#other--far-away\""),
651 "cross-mem never an anchor"
652 );
653
654 assert!(html.contains("ber-ziele"), "umlaut target linked: {html}");
658
659 assert!(
662 !html.contains("href=\"#bogus-frag\""),
663 "dangling fragment neutralised"
664 );
665 assert!(html.contains("(#bogus-frag — link removed)"), "{html}");
666
667 assert!(
670 !html.contains("href=\"javascript:"),
671 "javascript scheme stripped: {html}"
672 );
673 assert!(
674 html.contains("link removed"),
675 "neutralised destination surfaced"
676 );
677 assert_no_dangling_anchors(&html);
678
679 for fetching in [
686 "<img",
687 "<video",
688 "<audio",
689 "<iframe",
690 "<link ",
691 "<script src",
692 "@import",
693 "url(",
694 ] {
695 assert!(
696 !html.contains(fetching),
697 "self-containment violated by {fetching}"
698 );
699 }
700
701 let again = engine.render_html_export("specs", "2026-08-10").unwrap();
703 assert_eq!(html, again, "byte-deterministic");
704
705 let untouched_block = {
708 let start = html.find("id=\"specs--bösenberg-söhne-rev-21\"").unwrap();
709 let end = html[start..].find("</section>").unwrap() + start;
710 html[start..end].to_string()
711 };
712 let (actor, client) = cli_actor();
713 let mut edit = crate::engine::UpdateEntityArgs {
714 anchors: Vec::new(),
715 id: crate::entity::EntityId::new("specs", "target-entity"),
716 expected_hash: None,
717 sections: indexmap::IndexMap::from_iter([(
718 "purpose".to_string(),
719 "Geändert.".to_string(),
720 )]),
721 append_sections: indexmap::IndexMap::new(),
722 patch_sections: indexmap::IndexMap::new(),
723 metadata: indexmap::IndexMap::new(),
724 metadata_unset: Vec::new(),
725 declare_relations: Vec::new(),
726 dry_run: false,
727 relations_unset: Vec::new(),
728 anchors_unset: Vec::new(),
729 };
730 let _ = &mut edit;
731 engine
732 .update_entity(edit, actor, Some(&client), None)
733 .expect("edit lands");
734 let after = engine.render_html_export("specs", "2026-08-10").unwrap();
735 assert_ne!(html, after, "edit changes the export");
736 assert!(
737 after.contains(&untouched_block),
738 "untouched entity's region byte-identical after the edit"
739 );
740 assert!(after.contains("Geändert."), "edited content present");
741 }
742
743 #[test]
747 fn read_only_origin_stated_and_unknown_mem_refuses() {
748 let tmp = TempDir::new().unwrap();
749 let mem_dir = tmp.path().to_path_buf();
750 std::fs::write(
751 mem_dir.join("note.md"),
752 "---\ntype: spec\ncreated_date: 2026-01-01\nlast_modified: 2026-01-01\nlevel: M0\n---\n# Foreign Note\n\n## Identity\n\nI.\n\n## Purpose\n\nP.\n",
753 )
754 .unwrap();
755 let writer = FilesystemMemWriter::new(mem_dir.clone());
756 let mount = Mount {
757 mem: "foreign".to_string(),
758 schema: Some("default@1.0.0".parse().unwrap()),
759 storage: MountStorage::Folder { path: mem_dir },
760 capability: MountCapability::ReadOnly,
761 lifecycle: MountLifecycle::Eager,
762 cross_linkable: true,
763 migration_target: None,
764 };
765 let engine =
766 Engine::from_mounts(vec![(mount, Box::new(writer) as Box<dyn MemBackend>)]).unwrap();
767 let html = engine.render_html_export("foreign", "2026-08-10").unwrap();
768 assert!(
769 html.contains("third-party (read-only mount"),
770 "trust class stated: {html}"
771 );
772
773 let err = engine.render_html_export("nope", "2026-08-10").unwrap_err();
774 assert_eq!(err.code(), "UNKNOWN_MEM");
775 }
776}