1use std::io::IsTerminal;
27use std::path::{Path, PathBuf};
28
29use clap::Parser;
30use memstead_base::filesystem::publish::assemble_archive;
31use serde_json::json;
32use tempfile::NamedTempFile;
33
34use crate::CliError;
35use crate::auth::{credentials, device_flow, resolve_token};
36use crate::output::{ExitKind, print_json, print_markdown};
37use crate::registry::{self, ApiErrorBody, PublishError};
38use crate::setup::CliContext;
39
40#[derive(Parser, Debug)]
41pub struct Args {
42 #[arg(value_name = "PATH")]
46 pub archive: Option<PathBuf>,
47
48 #[arg(long, value_name = "NAME")]
54 pub mem: Option<String>,
55
56 #[arg(long, value_name = "NAME")]
60 pub scope: Option<String>,
61
62 #[arg(long, value_name = "TOKEN")]
65 pub token: Option<String>,
66
67 #[arg(long, value_name = "URL")]
69 pub registry: Option<String>,
70
71 #[arg(long, value_name = "SEMVER")]
77 pub version: Option<String>,
78
79 #[arg(long)]
92 pub redact_anchors: bool,
93
94 #[arg(long)]
99 pub dry_run: bool,
100}
101
102pub fn run(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
103 run_with_root(ctx, args, None)
104}
105
106fn run_with_root(
111 ctx: &CliContext,
112 args: Args,
113 root_override: Option<PathBuf>,
114) -> anyhow::Result<()> {
115 let base = registry::registry_base(args.registry.as_deref());
116 let host = registry::registry_host(&base);
117 let client = registry::build_http()?;
118
119 let target_version = match args.version.as_deref() {
124 Some(v) => {
125 if args.archive.is_some() {
126 return Err(CliError::new(
127 ExitKind::Validation,
128 "INVALID_INPUT",
129 "--version cannot be combined with a pre-built archive PATH (its version is already baked in) — drop the PATH and use --mem, or re-export at the new version",
130 )
131 .into());
132 }
133 if args.mem.is_none() {
134 return Err(CliError::new(
135 ExitKind::Validation,
136 "INVALID_INPUT",
137 "--version requires --mem <name> so the bump knows which mem to re-version",
138 )
139 .into());
140 }
141 Some(semver::Version::parse(v).map_err(|e| {
142 CliError::new(
143 ExitKind::Validation,
144 "INVALID_VERSION",
145 format!("--version {v:?} is not a valid semver: {e}"),
146 )
147 })?)
148 }
149 None => None,
150 };
151
152 if args.redact_anchors && args.archive.is_some() {
156 return Err(CliError::new(
157 ExitKind::Validation,
158 "INVALID_INPUT",
159 "--redact-anchors cannot be combined with a pre-built archive PATH (its \
160 anchors are already baked in) — drop the PATH and use --mem <name>, or run \
161 the bare `memstead publish` from the mem's folder, both of which assemble \
162 the archive and can redact it",
163 )
164 .into());
165 }
166
167 let mut resolved_version: Option<String> = None;
176 let (archive_path, _tempfile_guard): (PathBuf, Option<NamedTempFile>) =
177 if let Some(p) = args.archive {
178 (p, None)
179 } else if let Some(mem_name) = args.mem.as_deref() {
180 let workspace_root = resolve_workspace_root(root_override.as_deref())?;
181 let mut engine = ctx.cli_engine_at(&workspace_root)?.into_base();
182 if let Some(ver) = target_version.clone()
185 && !args.dry_run
186 {
187 engine
188 .set_mem_version(mem_name, ver, Some("version bump for registry publish"))
189 .map_err(CliError::from_engine_op)?;
190 }
191 resolved_version = target_version.as_ref().map(|v| v.to_string()).or_else(|| {
192 engine
193 .mem_config_for(mem_name)
194 .and_then(|c| c.version.clone())
195 .map(|v| v.to_string())
196 });
197 let bytes = engine
198 .export_mem_to_bytes(mem_name)
199 .map_err(CliError::from_engine_op)?;
200 stage_bytes_to_tempfile(&redact_if_requested(&args, bytes)?)?
201 } else {
202 let workspace_root = resolve_workspace_root(root_override.as_deref())?;
203 let engine_mem: Option<(memstead_base::Engine, String)> =
218 match ctx.cli_engine_at(&workspace_root) {
219 Ok(cli_engine) => {
220 let engine = cli_engine.into_base();
221 let writable: Vec<String> = engine
222 .mem_configs_named()
223 .filter(|(name, _)| engine.mem_router().is_writable(name))
224 .map(|(name, _)| name.to_string())
225 .collect();
226 match writable.len() {
227 1 => Some((engine, writable.into_iter().next().unwrap())),
228 0 => None,
229 _ => {
230 return Err(CliError::new(
231 ExitKind::Validation,
232 "AMBIGUOUS_MEM",
233 format!(
234 "multiple writable mems loaded ({}); pass --mem <name>",
235 writable.join(", ")
236 ),
237 )
238 .into());
239 }
240 }
241 }
242 Err(_) => None,
243 };
244 let bytes = match engine_mem {
245 Some((engine, mem_name)) => {
246 resolved_version = engine
247 .mem_config_for(&mem_name)
248 .and_then(|c| c.version.clone())
249 .map(|v| v.to_string());
250 engine
251 .export_mem_to_bytes(&mem_name)
252 .map_err(CliError::from_engine_op)?
253 }
254 None => assemble_archive(&workspace_root).map_err(|e| {
255 CliError::new(
256 ExitKind::Validation,
257 "ARCHIVE_ASSEMBLY_FAILED",
258 format!("assemble archive: {e}"),
259 )
260 })?,
261 };
262 stage_bytes_to_tempfile(&redact_if_requested(&args, bytes)?)?
263 };
264
265 if args.dry_run {
268 return emit_dry_run(
269 ctx,
270 &base,
271 &archive_path,
272 args.mem.as_deref(),
273 resolved_version.as_deref(),
274 args.scope.as_deref(),
275 );
276 }
277
278 if let Some(domain) = domain_scope(args.scope.as_deref()) {
283 let scope = args.scope.as_deref().expect("domain_scope implies a scope");
284 let sig = build_domain_signature(&archive_path, scope, &domain)?;
285 return match registry::publish(&client, &base, &archive_path, None, Some(scope), Some(&sig))
286 {
287 Ok(resp) => emit_success(ctx, &base, &resp),
288 Err(e) => Err(map_publish_error(e).into()),
289 };
290 }
291
292 let token = match resolve_token(&host, args.token.as_deref())? {
293 Some(r) => r.token,
294 None => {
295 if !std::io::stdin().is_terminal() {
296 return Err(CliError::new(
297 ExitKind::Generic,
298 "NOT_AUTHENTICATED",
299 "not logged in and stdin is not a TTY — set MEMSTEAD_TOKEN \
300 or run `memstead login` first",
301 )
302 .into());
303 }
304 login_inline(&client, &host)?
305 }
306 };
307
308 match registry::publish(
309 &client,
310 &base,
311 &archive_path,
312 Some(&token),
313 args.scope.as_deref(),
314 None,
315 ) {
316 Ok(resp) => emit_success(ctx, &base, &resp),
317 Err(e) => Err(map_publish_error(e).into()),
318 }
319}
320
321fn redact_if_requested(args: &Args, bytes: Vec<u8>) -> Result<Vec<u8>, CliError> {
325 if !args.redact_anchors {
326 return Ok(bytes);
327 }
328 memstead_base::filesystem::publish::redact_archive_anchors(&bytes).map_err(|e| {
329 CliError::new(
330 ExitKind::Validation,
331 "ARCHIVE_ASSEMBLY_FAILED",
332 format!("redact anchors: {e}"),
333 )
334 })
335}
336
337fn domain_scope(scope: Option<&str>) -> Option<String> {
341 let (prefix, handle) = scope?.split_once(':')?;
342 if prefix.contains('.') && !handle.is_empty() {
343 Some(prefix.to_ascii_lowercase())
344 } else {
345 None
346 }
347}
348
349#[cfg(feature = "mem-repo")]
353fn build_domain_signature(
354 archive_path: &Path,
355 scope: &str,
356 domain: &str,
357) -> anyhow::Result<registry::DomainSignature> {
358 use memstead_base::domain_authority_wire::signing_payload;
359 use memstead_git_branch::validator::validate_and_normalize_archive;
360 use sha2::{Digest, Sha256};
361
362 use crate::auth::domain_key;
363
364 let bytes = std::fs::read(archive_path).map_err(|e| {
365 CliError::new(
366 ExitKind::Generic,
367 "ARCHIVE_READ_FAILED",
368 format!("read archive: {e}"),
369 )
370 })?;
371 let validated = validate_and_normalize_archive(&bytes).map_err(|e| {
372 CliError::new(
373 ExitKind::Validation,
374 "ARCHIVE_INVALID",
375 format!("archive failed local validation before signing: {e}"),
376 )
377 })?;
378 let content_sha256 = {
379 let mut h = Sha256::new();
380 h.update(&validated.canonical_bytes);
381 h.finalize()
382 .iter()
383 .map(|b| format!("{b:02x}"))
384 .collect::<String>()
385 };
386 let name = validated.config.name.clone();
387 let version = validated.config.version.to_string();
388
389 let signing = domain_key::load(domain)
390 .map_err(|e| CliError::new(ExitKind::NotFound, "DOMAIN_KEY_NOT_FOUND", e.to_string()))?;
391 let timestamp = std::time::SystemTime::now()
392 .duration_since(std::time::UNIX_EPOCH)
393 .map(|d| d.as_secs() as i64)
394 .unwrap_or(0);
395 let payload = signing_payload(&content_sha256, scope, &name, &version, timestamp);
396 Ok(registry::DomainSignature {
397 key: domain_key::public_key_string(&signing),
398 signature: domain_key::sign(&signing, &payload),
399 timestamp,
400 })
401}
402
403#[cfg(not(feature = "mem-repo"))]
407fn build_domain_signature(
408 _archive_path: &Path,
409 _scope: &str,
410 _domain: &str,
411) -> anyhow::Result<registry::DomainSignature> {
412 Err(CliError::new(
413 ExitKind::Generic,
414 "DOMAIN_PUBLISH_UNAVAILABLE",
415 "domain publishing requires the full `memstead` build (the lean build cannot \
416 canonicalize archives for signing)",
417 )
418 .into())
419}
420
421fn emit_dry_run(
426 ctx: &CliContext,
427 base: &str,
428 archive_path: &Path,
429 mem: Option<&str>,
430 version: Option<&str>,
431 scope: Option<&str>,
432) -> anyhow::Result<()> {
433 let size = std::fs::metadata(archive_path)
434 .map(|m| m.len())
435 .unwrap_or(0);
436 let mem_label = mem.unwrap_or("(workspace mem)");
437 let version_label = version.unwrap_or("(from mem config / archive)");
438 if ctx.json {
439 print_json(&json!({
440 "dry_run": true,
441 "mem": mem,
442 "version": version,
443 "scope": scope,
444 "archive_bytes": size,
445 "registry": base,
446 "published": false,
447 }))?;
448 } else {
449 let scope_label = match scope {
450 Some(s) => format!("`{s}` (override)"),
451 None => "derived from your GitHub login".to_string(),
452 };
453 print_markdown(&format!(
454 "# Dry run — would publish\n\n\
455 - Mem: `{mem_label}`\n\
456 - Version: `{version_label}`\n\
457 - Scope: {scope_label}\n\
458 - Archive: {size} bytes\n\
459 - Registry: {base}\n\n\
460 Nothing was published and nothing was changed.",
461 ));
462 }
463 Ok(())
464}
465
466fn find_filesystem_workspace_root() -> anyhow::Result<PathBuf> {
471 let cwd = std::env::current_dir().map_err(|e| {
472 CliError::new(
473 ExitKind::Generic,
474 crate::INTERNAL_CODE,
475 format!("read cwd: {e}"),
476 )
477 })?;
478 let mut current: &Path = &cwd;
479 loop {
480 if memstead_base::is_workspace_root(current) {
481 return Ok(current.to_path_buf());
482 }
483 match current.parent() {
484 Some(p) => current = p,
485 None => {
486 return Err(CliError::new(
487 ExitKind::NotFound,
488 "WORKSPACE_NOT_INITIALISED",
489 format!(
490 "no workspace found from {} or any ancestor (missing \
491 .memstead/workspace.toml) — run `memstead init` first, pass \
492 --workspace <path>, or supply an archive path",
493 cwd.display()
494 ),
495 )
496 .into());
497 }
498 }
499 }
500}
501
502fn resolve_workspace_root(root_override: Option<&Path>) -> anyhow::Result<PathBuf> {
505 match root_override {
509 Some(p) => Ok(p.to_path_buf()),
510 None => find_filesystem_workspace_root(),
511 }
512}
513
514fn stage_bytes_to_tempfile(bytes: &[u8]) -> anyhow::Result<(PathBuf, Option<NamedTempFile>)> {
518 let tempfile = NamedTempFile::new().map_err(|e| {
519 CliError::new(
520 ExitKind::Generic,
521 crate::INTERNAL_CODE,
522 format!("tempfile: {e}"),
523 )
524 })?;
525 std::fs::write(tempfile.path(), bytes).map_err(|e| {
526 CliError::new(
527 ExitKind::Generic,
528 crate::INTERNAL_CODE,
529 format!("write tempfile {}: {e}", tempfile.path().display()),
530 )
531 })?;
532 let path = tempfile.path().to_path_buf();
533 Ok((path, Some(tempfile)))
534}
535
536fn login_inline(client: &reqwest::blocking::Client, host: &str) -> anyhow::Result<String> {
537 println!("Not logged in — starting GitHub Device Flow…");
538 let outcome = device_flow::run(
539 client,
540 device_flow::MEMSTEAD_GITHUB_CLIENT_ID,
541 device_flow::MEMSTEAD_GITHUB_SCOPE,
542 |url| {
543 let _ = device_flow::open_browser(url);
544 },
545 )
546 .map_err(|e| {
547 CliError::new(
548 ExitKind::Generic,
549 "LOGIN_FAILED",
550 format!("login failed: {e}"),
551 )
552 })?;
553
554 let user_login = fetch_login(client, &outcome.access_token).unwrap_or_default();
556
557 let entry = credentials::Entry::new(
558 outcome.access_token.clone(),
559 user_login,
560 outcome.scopes.clone(),
561 );
562 credentials::save_for(host, entry)?;
563
564 Ok(outcome.access_token)
565}
566
567fn fetch_login(client: &reqwest::blocking::Client, token: &str) -> anyhow::Result<String> {
568 let base = std::env::var("MEMSTEAD_GITHUB_API_BASE")
569 .unwrap_or_else(|_| "https://api.github.com".to_string());
570 let url = format!("{}/user", base.trim_end_matches('/'));
571 let resp = client
572 .get(url)
573 .bearer_auth(token)
574 .header("accept", "application/vnd.github+json")
575 .send()?;
576 if !resp.status().is_success() {
577 anyhow::bail!("GitHub /user returned {}", resp.status());
578 }
579 #[derive(serde::Deserialize)]
580 struct User {
581 login: String,
582 }
583 let user: User = resp.json()?;
584 Ok(user.login)
585}
586
587fn emit_success(
588 ctx: &CliContext,
589 base: &str,
590 resp: ®istry::PublishResponse,
591) -> anyhow::Result<()> {
592 let full_url = format!("{}{}", base, resp.url);
593 let demoted = resp.current.as_deref().filter(|cur| *cur != resp.version);
598 if ctx.json {
599 print_json(&json!({
600 "ok": true,
601 "scope": resp.scope,
602 "name": resp.name,
603 "version": resp.version,
604 "current": resp.current,
605 "url": full_url,
606 }))?;
607 } else {
608 let mut block = format!(
609 "# Published {}/{} v{}\n\n- URL: {}",
610 resp.scope, resp.name, resp.version, full_url,
611 );
612 if let Some(cur) = demoted {
613 block.push_str(&format!(
614 "\n\n> Note: `current` stays at v{cur} — you published an older version, \
615 so it is retained and resolvable but is not the default users get.",
616 ));
617 }
618 print_markdown(&block);
619 }
620 Ok(())
621}
622
623fn map_publish_error(err: PublishError) -> CliError {
624 match err {
625 PublishError::Io(e) => CliError::new(
626 ExitKind::Generic,
627 "ARCHIVE_READ_FAILED",
628 format!("cannot read archive: {e}"),
629 ),
630 PublishError::Network(e) => CliError::new(
631 ExitKind::Generic,
632 "NETWORK_ERROR",
633 format!("network error: {e}"),
634 ),
635 PublishError::Malformed(e) => CliError::new(
636 ExitKind::Generic,
637 "REGISTRY_MALFORMED_RESPONSE",
638 format!("registry sent an unparseable success response: {e}"),
639 ),
640 PublishError::Raw { status, text } => CliError::new(
641 ExitKind::Generic,
642 "REGISTRY_ERROR",
643 format!("registry returned {status}: {text}"),
644 ),
645 PublishError::Api { status, envelope } => map_api_error(status, envelope),
646 }
647}
648
649fn map_api_error(status: reqwest::StatusCode, envelope: ApiErrorBody) -> CliError {
650 let kind = match status.as_u16() {
651 400 => ExitKind::Validation,
652 401 | 403 => ExitKind::Generic,
653 404 => ExitKind::NotFound,
654 410 => ExitKind::Generic,
655 413 | 429 => ExitKind::Generic,
656 _ => ExitKind::Generic,
657 };
658 let code: &'static str = match status.as_u16() {
659 400 => "REGISTRY_VALIDATION_FAILED",
660 401 => "NOT_AUTHENTICATED",
661 403 => "FORBIDDEN",
662 404 => "REGISTRY_NOT_FOUND",
663 410 => "GONE",
664 413 => "ARCHIVE_TOO_LARGE",
665 429 => "RATE_LIMITED",
666 _ => "REGISTRY_ERROR",
667 };
668
669 let mut msg = match status.as_u16() {
670 400 => {
671 let variant = envelope
672 .variant
673 .clone()
674 .unwrap_or_else(|| "ValidationFailed".to_string());
675 let detail = envelope
676 .detail
677 .clone()
678 .unwrap_or_else(|| "validation failed (no detail)".to_string());
679 if let Some(path) = envelope.path.as_deref() {
680 format!("{variant} at {path}: {detail}")
681 } else {
682 format!("{variant}: {detail}")
683 }
684 }
685 401 => {
686 "unauthorized — set MEMSTEAD_TOKEN, run `memstead login`, or pass --token".to_string()
687 }
688 403 => envelope
689 .detail
690 .clone()
691 .map(|d| format!("forbidden: {d}"))
692 .unwrap_or_else(|| "forbidden".to_string()),
693 404 => "registry returned 404 — is the URL correct?".to_string(),
694 410 => envelope
695 .detail
696 .clone()
697 .map(|d| format!("gone: {d}"))
698 .unwrap_or_else(|| "content is gone (taken down or deny-listed)".to_string()),
699 413 => "archive exceeds the 2 MB publisher cap".to_string(),
700 429 => {
701 let retry = envelope.retry_after_seconds.unwrap_or(0);
702 if retry > 0 {
703 format!("rate-limited — retry after {retry}s")
704 } else {
705 "rate-limited".to_string()
706 }
707 }
708 _ => envelope
709 .detail
710 .clone()
711 .unwrap_or_else(|| format!("registry returned {status}")),
712 };
713
714 if !envelope.error.is_empty() && !msg.to_ascii_lowercase().contains(&envelope.error) {
717 msg = format!("{msg} [{}]", envelope.error);
718 }
719
720 CliError::new(kind, code, msg)
721}
722
723#[cfg(test)]
724mod tests {
725 use super::*;
726 use memstead_base::filesystem::config::{WorkspaceConfig, write_workspace_config};
727 use memstead_schema::SchemaRef;
728 use tempfile::TempDir;
729
730 fn write_publishable_workspace(tmp: &TempDir, name: &str) {
731 let memstead_dir = tmp.path().join(".memstead");
734 std::fs::create_dir_all(&memstead_dir).unwrap();
735 std::fs::write(
736 memstead_dir.join("workspace.toml"),
737 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
738 )
739 .unwrap();
740 let pin: SchemaRef = "default@1.0.0".parse().unwrap();
745 let cfg = WorkspaceConfig::new(name, pin);
746 write_workspace_config(tmp.path(), &cfg).unwrap();
747 let cfg_path = tmp.path().join(".memstead").join("config.json");
751 let raw = std::fs::read_to_string(&cfg_path).unwrap();
752 let mut value: serde_json::Value = serde_json::from_str(&raw).unwrap();
753 value["version"] = serde_json::json!("0.1.0");
754 std::fs::write(&cfg_path, serde_json::to_string_pretty(&value).unwrap()).unwrap();
755 }
756
757 async fn spawn_fixture_publish_registry() -> (
762 String,
763 std::sync::Arc<std::sync::Mutex<Vec<u8>>>,
764 tokio::task::JoinHandle<()>,
765 ) {
766 use axum::{Json, Router, extract::State, http::StatusCode, routing::post};
767 use std::sync::{Arc, Mutex};
768
769 let captured: Arc<Mutex<Vec<u8>>> = Arc::new(Mutex::new(Vec::new()));
770 let captured_clone = captured.clone();
771 let app: Router = Router::new()
772 .route(
773 "/api/publish",
774 post(
775 move |State(buf): State<Arc<Mutex<Vec<u8>>>>, body: axum::body::Bytes| async move {
776 *buf.lock().unwrap() = body.to_vec();
777 (
778 StatusCode::OK,
779 Json(serde_json::json!({
780 "ok": true,
781 "scope": "fixture",
782 "name": "demo",
783 "version": "0.1.0",
784 "url": "/v/fixture/demo",
785 })),
786 )
787 },
788 ),
789 )
790 .with_state(captured_clone);
791 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
792 let addr = listener.local_addr().unwrap();
793 let handle = tokio::spawn(async move {
794 axum::serve(listener, app).await.unwrap();
795 });
796 (format!("http://{addr}"), captured, handle)
797 }
798
799 #[tokio::test(flavor = "multi_thread")]
800 async fn publish_assembles_from_workspace_when_no_archive_arg() {
801 let tmp = TempDir::new().unwrap();
802 write_publishable_workspace(&tmp, "demo");
803
804 let (base, captured, handle) = spawn_fixture_publish_registry().await;
805
806 let workspace = tmp.path().to_path_buf();
807 let base_clone = base.clone();
808 let captured_clone = captured.clone();
809 let result = tokio::task::spawn_blocking(move || {
810 let ctx = CliContext {
811 json: false,
812 quiet: false,
813 role: Default::default(),
814 };
815 run_with_root(
816 &ctx,
817 Args {
818 archive: None,
819 mem: None,
820 scope: None,
821 version: None,
822 dry_run: false,
823 redact_anchors: false,
824 token: Some("fixture-token".to_string()),
825 registry: Some(base_clone),
826 },
827 Some(workspace),
828 )?;
829 let body = captured_clone.lock().unwrap().clone();
830 Ok::<Vec<u8>, anyhow::Error>(body)
831 })
832 .await
833 .unwrap();
834 handle.abort();
835 let body = result.unwrap();
836
837 assert!(body.len() > 4);
839 assert_eq!(&body[0..4], b"PK\x03\x04");
840 }
841
842 #[test]
849 fn publish_errors_when_root_lacks_workspace_shape() {
850 let tmp = TempDir::new().unwrap();
851 let ctx = CliContext {
853 json: false,
854 quiet: false,
855 role: Default::default(),
856 };
857 let err = run_with_root(
858 &ctx,
859 Args {
860 archive: None,
861 mem: None,
862 scope: None,
863 version: None,
864 dry_run: false,
865 redact_anchors: false,
866 token: Some("fixture-token".to_string()),
867 registry: Some("http://127.0.0.1:1".to_string()),
868 },
869 Some(tmp.path().to_path_buf()),
870 )
871 .unwrap_err();
872 let msg = err.to_string();
873 assert!(!msg.is_empty(), "marker-less root must error, got: {msg}");
874 }
875
876 #[test]
877 fn publish_mem_flag_routes_through_engine_and_maps_unknown_mem() {
878 let tmp = TempDir::new().unwrap();
888 write_publishable_workspace(&tmp, "demo");
889 let ctx = CliContext {
890 json: false,
891 quiet: false,
892 role: Default::default(),
893 };
894 let err = run_with_root(
895 &ctx,
896 Args {
897 archive: None,
898 mem: Some("nonexistent".to_string()),
899 scope: None,
900 version: None,
901 dry_run: false,
902 redact_anchors: false,
903 token: Some("fixture-token".to_string()),
904 registry: Some("http://127.0.0.1:1".to_string()),
905 },
906 Some(tmp.path().to_path_buf()),
907 )
908 .unwrap_err();
909 let msg = err.to_string();
910 assert!(
911 msg.contains("unknown mem") || msg.contains("nonexistent"),
912 "expected an engine UNKNOWN_MEM error from the --mem path, got: {msg}"
913 );
914 }
915
916 #[test]
917 fn publish_version_without_mem_is_rejected_before_any_io() {
918 let ctx = CliContext {
922 json: false,
923 quiet: false,
924 role: Default::default(),
925 };
926 let err = run(
927 &ctx,
928 Args {
929 archive: None,
930 mem: None,
931 scope: None,
932 version: Some("0.2.0".to_string()),
933 dry_run: false,
934 redact_anchors: false,
935 token: None,
936 registry: Some("http://127.0.0.1:1".to_string()),
937 },
938 )
939 .unwrap_err();
940 let msg = err.to_string();
941 assert!(
942 msg.contains("--version requires --mem"),
943 "expected a --version-requires-mem refusal, got: {msg}"
944 );
945 }
946
947 #[tokio::test(flavor = "multi_thread")]
948 async fn dry_run_posts_nothing() {
949 let tmp = TempDir::new().unwrap();
953 write_publishable_workspace(&tmp, "demo");
954
955 let (base, captured, handle) = spawn_fixture_publish_registry().await;
956
957 let workspace = tmp.path().to_path_buf();
958 let base_clone = base.clone();
959 let result = tokio::task::spawn_blocking(move || {
960 let ctx = CliContext {
961 json: false,
962 quiet: false,
963 role: Default::default(),
964 };
965 run_with_root(
966 &ctx,
967 Args {
968 archive: None,
969 mem: None,
970 scope: None,
971 version: None,
972 dry_run: true,
973 redact_anchors: false,
974 token: None,
975 registry: Some(base_clone),
976 },
977 Some(workspace),
978 )?;
979 Ok::<(), anyhow::Error>(())
980 })
981 .await
982 .unwrap();
983 handle.abort();
984 result.unwrap();
985
986 assert!(
987 captured.lock().unwrap().is_empty(),
988 "dry-run must not POST anything to the registry"
989 );
990 }
991
992 #[test]
999 fn redact_anchors_refuses_prebuilt_archive_path() {
1000 let ctx = CliContext {
1001 json: false,
1002 quiet: false,
1003 role: Default::default(),
1004 };
1005 let err = run_with_root(
1006 &ctx,
1007 Args {
1008 archive: Some(PathBuf::from("/nonexistent/some.mem")),
1009 mem: None,
1010 scope: None,
1011 version: None,
1012 dry_run: false,
1013 redact_anchors: true,
1014 token: Some("fixture-token".to_string()),
1015 registry: Some("http://127.0.0.1:1".to_string()),
1016 },
1017 None,
1018 )
1019 .unwrap_err();
1020 let msg = err.to_string();
1021 assert!(
1022 msg.contains("--redact-anchors") && msg.contains("baked in"),
1023 "typed refusal names the flag and the alternative: {msg}"
1024 );
1025 }
1026
1027 #[tokio::test(flavor = "multi_thread")]
1031 async fn redacted_publish_posts_sentinel_sidecar() {
1032 let tmp = TempDir::new().unwrap();
1033 write_publishable_workspace(&tmp, "demo");
1034 std::fs::write(
1035 tmp.path().join("first.md"),
1036 "---\ntype: spec\n---\n# First\n",
1037 )
1038 .unwrap();
1039 let sidecar_path = tmp.path().join(".memstead").join("anchors.json");
1040 let local = br#"{"version":1,"entities":{"demo--first":[{"artifact":"src/private.rs","grain":"file","class":"anchored","hash_stability":"stable","hash":"h1"}]}}"#;
1041 std::fs::write(&sidecar_path, local).unwrap();
1042
1043 let (base, captured, handle) = spawn_fixture_publish_registry().await;
1044 let workspace = tmp.path().to_path_buf();
1045 let base_clone = base.clone();
1046 let captured_clone = captured.clone();
1047 let body = tokio::task::spawn_blocking(move || {
1048 let ctx = CliContext {
1049 json: false,
1050 quiet: false,
1051 role: Default::default(),
1052 };
1053 run_with_root(
1054 &ctx,
1055 Args {
1056 archive: None,
1057 mem: None,
1058 scope: None,
1059 version: None,
1060 dry_run: false,
1061 redact_anchors: true,
1062 token: Some("fixture-token".to_string()),
1063 registry: Some(base_clone),
1064 },
1065 Some(workspace),
1066 )?;
1067 Ok::<Vec<u8>, anyhow::Error>(captured_clone.lock().unwrap().clone())
1068 })
1069 .await
1070 .unwrap()
1071 .unwrap();
1072 handle.abort();
1073
1074 let posted = String::from_utf8_lossy(&body).into_owned();
1075 assert!(
1076 posted.contains("[redacted]"),
1077 "posted package carries the sentinel"
1078 );
1079 assert!(
1080 !posted.contains("src/private.rs"),
1081 "posted package must not carry the artifact path"
1082 );
1083 let after = std::fs::read(&sidecar_path).unwrap();
1085 assert_eq!(after, local, "local sidecar bytes are byte-identical");
1086 }
1087}