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 bytes = assemble_archive(&workspace_root).map_err(|e| {
204 CliError::new(
205 ExitKind::Validation,
206 "ARCHIVE_ASSEMBLY_FAILED",
207 format!("assemble archive: {e}"),
208 )
209 })?;
210 stage_bytes_to_tempfile(&redact_if_requested(&args, bytes)?)?
211 };
212
213 if args.dry_run {
216 return emit_dry_run(
217 ctx,
218 &base,
219 &archive_path,
220 args.mem.as_deref(),
221 resolved_version.as_deref(),
222 args.scope.as_deref(),
223 );
224 }
225
226 if let Some(domain) = domain_scope(args.scope.as_deref()) {
231 let scope = args.scope.as_deref().expect("domain_scope implies a scope");
232 let sig = build_domain_signature(&archive_path, scope, &domain)?;
233 return match registry::publish(&client, &base, &archive_path, None, Some(scope), Some(&sig))
234 {
235 Ok(resp) => emit_success(ctx, &base, &resp),
236 Err(e) => Err(map_publish_error(e).into()),
237 };
238 }
239
240 let token = match resolve_token(&host, args.token.as_deref())? {
241 Some(r) => r.token,
242 None => {
243 if !std::io::stdin().is_terminal() {
244 return Err(CliError::new(
245 ExitKind::Generic,
246 "NOT_AUTHENTICATED",
247 "not logged in and stdin is not a TTY — set MEMSTEAD_TOKEN \
248 or run `memstead login` first",
249 )
250 .into());
251 }
252 login_inline(&client, &host)?
253 }
254 };
255
256 match registry::publish(
257 &client,
258 &base,
259 &archive_path,
260 Some(&token),
261 args.scope.as_deref(),
262 None,
263 ) {
264 Ok(resp) => emit_success(ctx, &base, &resp),
265 Err(e) => Err(map_publish_error(e).into()),
266 }
267}
268
269fn redact_if_requested(args: &Args, bytes: Vec<u8>) -> Result<Vec<u8>, CliError> {
273 if !args.redact_anchors {
274 return Ok(bytes);
275 }
276 memstead_base::filesystem::publish::redact_archive_anchors(&bytes).map_err(|e| {
277 CliError::new(
278 ExitKind::Validation,
279 "ARCHIVE_ASSEMBLY_FAILED",
280 format!("redact anchors: {e}"),
281 )
282 })
283}
284
285fn domain_scope(scope: Option<&str>) -> Option<String> {
289 let (prefix, handle) = scope?.split_once(':')?;
290 if prefix.contains('.') && !handle.is_empty() {
291 Some(prefix.to_ascii_lowercase())
292 } else {
293 None
294 }
295}
296
297#[cfg(feature = "mem-repo")]
301fn build_domain_signature(
302 archive_path: &Path,
303 scope: &str,
304 domain: &str,
305) -> anyhow::Result<registry::DomainSignature> {
306 use memstead_base::domain_authority_wire::signing_payload;
307 use memstead_git_branch::validator::validate_and_normalize_archive;
308 use sha2::{Digest, Sha256};
309
310 use crate::auth::domain_key;
311
312 let bytes = std::fs::read(archive_path).map_err(|e| {
313 CliError::new(
314 ExitKind::Generic,
315 "ARCHIVE_READ_FAILED",
316 format!("read archive: {e}"),
317 )
318 })?;
319 let validated = validate_and_normalize_archive(&bytes).map_err(|e| {
320 CliError::new(
321 ExitKind::Validation,
322 "ARCHIVE_INVALID",
323 format!("archive failed local validation before signing: {e}"),
324 )
325 })?;
326 let content_sha256 = {
327 let mut h = Sha256::new();
328 h.update(&validated.canonical_bytes);
329 h.finalize()
330 .iter()
331 .map(|b| format!("{b:02x}"))
332 .collect::<String>()
333 };
334 let name = validated.config.name.clone();
335 let version = validated.config.version.to_string();
336
337 let signing = domain_key::load(domain)
338 .map_err(|e| CliError::new(ExitKind::NotFound, "DOMAIN_KEY_NOT_FOUND", e.to_string()))?;
339 let timestamp = std::time::SystemTime::now()
340 .duration_since(std::time::UNIX_EPOCH)
341 .map(|d| d.as_secs() as i64)
342 .unwrap_or(0);
343 let payload = signing_payload(&content_sha256, scope, &name, &version, timestamp);
344 Ok(registry::DomainSignature {
345 key: domain_key::public_key_string(&signing),
346 signature: domain_key::sign(&signing, &payload),
347 timestamp,
348 })
349}
350
351#[cfg(not(feature = "mem-repo"))]
355fn build_domain_signature(
356 _archive_path: &Path,
357 _scope: &str,
358 _domain: &str,
359) -> anyhow::Result<registry::DomainSignature> {
360 Err(CliError::new(
361 ExitKind::Generic,
362 "DOMAIN_PUBLISH_UNAVAILABLE",
363 "domain publishing requires the full `memstead` build (the lean build cannot \
364 canonicalize archives for signing)",
365 )
366 .into())
367}
368
369fn emit_dry_run(
374 ctx: &CliContext,
375 base: &str,
376 archive_path: &Path,
377 mem: Option<&str>,
378 version: Option<&str>,
379 scope: Option<&str>,
380) -> anyhow::Result<()> {
381 let size = std::fs::metadata(archive_path)
382 .map(|m| m.len())
383 .unwrap_or(0);
384 let mem_label = mem.unwrap_or("(workspace mem)");
385 let version_label = version.unwrap_or("(from mem config / archive)");
386 if ctx.json {
387 print_json(&json!({
388 "dry_run": true,
389 "mem": mem,
390 "version": version,
391 "scope": scope,
392 "archive_bytes": size,
393 "registry": base,
394 "published": false,
395 }))?;
396 } else {
397 let scope_label = match scope {
398 Some(s) => format!("`{s}` (override)"),
399 None => "derived from your GitHub login".to_string(),
400 };
401 print_markdown(&format!(
402 "# Dry run — would publish\n\n\
403 - Mem: `{mem_label}`\n\
404 - Version: `{version_label}`\n\
405 - Scope: {scope_label}\n\
406 - Archive: {size} bytes\n\
407 - Registry: {base}\n\n\
408 Nothing was published and nothing was changed.",
409 ));
410 }
411 Ok(())
412}
413
414fn find_filesystem_workspace_root() -> anyhow::Result<PathBuf> {
419 let cwd = std::env::current_dir().map_err(|e| {
420 CliError::new(
421 ExitKind::Generic,
422 crate::INTERNAL_CODE,
423 format!("read cwd: {e}"),
424 )
425 })?;
426 let mut current: &Path = &cwd;
427 loop {
428 if memstead_base::is_workspace_root(current) {
429 return Ok(current.to_path_buf());
430 }
431 match current.parent() {
432 Some(p) => current = p,
433 None => {
434 return Err(CliError::new(
435 ExitKind::NotFound,
436 "WORKSPACE_NOT_INITIALISED",
437 format!(
438 "no workspace found from {} or any ancestor (missing \
439 .memstead/workspace.toml) — run `memstead init` first, pass \
440 --workspace <path>, or supply an archive path",
441 cwd.display()
442 ),
443 )
444 .into());
445 }
446 }
447 }
448}
449
450fn resolve_workspace_root(root_override: Option<&Path>) -> anyhow::Result<PathBuf> {
453 match root_override {
457 Some(p) => Ok(p.to_path_buf()),
458 None => find_filesystem_workspace_root(),
459 }
460}
461
462fn stage_bytes_to_tempfile(bytes: &[u8]) -> anyhow::Result<(PathBuf, Option<NamedTempFile>)> {
466 let tempfile = NamedTempFile::new().map_err(|e| {
467 CliError::new(
468 ExitKind::Generic,
469 crate::INTERNAL_CODE,
470 format!("tempfile: {e}"),
471 )
472 })?;
473 std::fs::write(tempfile.path(), bytes).map_err(|e| {
474 CliError::new(
475 ExitKind::Generic,
476 crate::INTERNAL_CODE,
477 format!("write tempfile {}: {e}", tempfile.path().display()),
478 )
479 })?;
480 let path = tempfile.path().to_path_buf();
481 Ok((path, Some(tempfile)))
482}
483
484fn login_inline(client: &reqwest::blocking::Client, host: &str) -> anyhow::Result<String> {
485 println!("Not logged in — starting GitHub Device Flow…");
486 let outcome = device_flow::run(
487 client,
488 device_flow::MEMSTEAD_GITHUB_CLIENT_ID,
489 device_flow::MEMSTEAD_GITHUB_SCOPE,
490 |url| {
491 let _ = device_flow::open_browser(url);
492 },
493 )
494 .map_err(|e| {
495 CliError::new(
496 ExitKind::Generic,
497 "LOGIN_FAILED",
498 format!("login failed: {e}"),
499 )
500 })?;
501
502 let user_login = fetch_login(client, &outcome.access_token).unwrap_or_default();
504
505 let entry = credentials::Entry::new(
506 outcome.access_token.clone(),
507 user_login,
508 outcome.scopes.clone(),
509 );
510 credentials::save_for(host, entry)?;
511
512 Ok(outcome.access_token)
513}
514
515fn fetch_login(client: &reqwest::blocking::Client, token: &str) -> anyhow::Result<String> {
516 let base = std::env::var("MEMSTEAD_GITHUB_API_BASE")
517 .unwrap_or_else(|_| "https://api.github.com".to_string());
518 let url = format!("{}/user", base.trim_end_matches('/'));
519 let resp = client
520 .get(url)
521 .bearer_auth(token)
522 .header("accept", "application/vnd.github+json")
523 .send()?;
524 if !resp.status().is_success() {
525 anyhow::bail!("GitHub /user returned {}", resp.status());
526 }
527 #[derive(serde::Deserialize)]
528 struct User {
529 login: String,
530 }
531 let user: User = resp.json()?;
532 Ok(user.login)
533}
534
535fn emit_success(
536 ctx: &CliContext,
537 base: &str,
538 resp: ®istry::PublishResponse,
539) -> anyhow::Result<()> {
540 let full_url = format!("{}{}", base, resp.url);
541 let demoted = resp.current.as_deref().filter(|cur| *cur != resp.version);
546 if ctx.json {
547 print_json(&json!({
548 "ok": true,
549 "scope": resp.scope,
550 "name": resp.name,
551 "version": resp.version,
552 "current": resp.current,
553 "url": full_url,
554 }))?;
555 } else {
556 let mut block = format!(
557 "# Published {}/{} v{}\n\n- URL: {}",
558 resp.scope, resp.name, resp.version, full_url,
559 );
560 if let Some(cur) = demoted {
561 block.push_str(&format!(
562 "\n\n> Note: `current` stays at v{cur} — you published an older version, \
563 so it is retained and resolvable but is not the default users get.",
564 ));
565 }
566 print_markdown(&block);
567 }
568 Ok(())
569}
570
571fn map_publish_error(err: PublishError) -> CliError {
572 match err {
573 PublishError::Io(e) => CliError::new(
574 ExitKind::Generic,
575 "ARCHIVE_READ_FAILED",
576 format!("cannot read archive: {e}"),
577 ),
578 PublishError::Network(e) => CliError::new(
579 ExitKind::Generic,
580 "NETWORK_ERROR",
581 format!("network error: {e}"),
582 ),
583 PublishError::Malformed(e) => CliError::new(
584 ExitKind::Generic,
585 "REGISTRY_MALFORMED_RESPONSE",
586 format!("registry sent an unparseable success response: {e}"),
587 ),
588 PublishError::Raw { status, text } => CliError::new(
589 ExitKind::Generic,
590 "REGISTRY_ERROR",
591 format!("registry returned {status}: {text}"),
592 ),
593 PublishError::Api { status, envelope } => map_api_error(status, envelope),
594 }
595}
596
597fn map_api_error(status: reqwest::StatusCode, envelope: ApiErrorBody) -> CliError {
598 let kind = match status.as_u16() {
599 400 => ExitKind::Validation,
600 401 | 403 => ExitKind::Generic,
601 404 => ExitKind::NotFound,
602 410 => ExitKind::Generic,
603 413 | 429 => ExitKind::Generic,
604 _ => ExitKind::Generic,
605 };
606 let code: &'static str = match status.as_u16() {
607 400 => "REGISTRY_VALIDATION_FAILED",
608 401 => "NOT_AUTHENTICATED",
609 403 => "FORBIDDEN",
610 404 => "REGISTRY_NOT_FOUND",
611 410 => "GONE",
612 413 => "ARCHIVE_TOO_LARGE",
613 429 => "RATE_LIMITED",
614 _ => "REGISTRY_ERROR",
615 };
616
617 let mut msg = match status.as_u16() {
618 400 => {
619 let variant = envelope
620 .variant
621 .clone()
622 .unwrap_or_else(|| "ValidationFailed".to_string());
623 let detail = envelope
624 .detail
625 .clone()
626 .unwrap_or_else(|| "validation failed (no detail)".to_string());
627 if let Some(path) = envelope.path.as_deref() {
628 format!("{variant} at {path}: {detail}")
629 } else {
630 format!("{variant}: {detail}")
631 }
632 }
633 401 => {
634 "unauthorized — set MEMSTEAD_TOKEN, run `memstead login`, or pass --token".to_string()
635 }
636 403 => envelope
637 .detail
638 .clone()
639 .map(|d| format!("forbidden: {d}"))
640 .unwrap_or_else(|| "forbidden".to_string()),
641 404 => "registry returned 404 — is the URL correct?".to_string(),
642 410 => envelope
643 .detail
644 .clone()
645 .map(|d| format!("gone: {d}"))
646 .unwrap_or_else(|| "content is gone (taken down or deny-listed)".to_string()),
647 413 => "archive exceeds the 2 MB publisher cap".to_string(),
648 429 => {
649 let retry = envelope.retry_after_seconds.unwrap_or(0);
650 if retry > 0 {
651 format!("rate-limited — retry after {retry}s")
652 } else {
653 "rate-limited".to_string()
654 }
655 }
656 _ => envelope
657 .detail
658 .clone()
659 .unwrap_or_else(|| format!("registry returned {status}")),
660 };
661
662 if !envelope.error.is_empty() && !msg.to_ascii_lowercase().contains(&envelope.error) {
665 msg = format!("{msg} [{}]", envelope.error);
666 }
667
668 CliError::new(kind, code, msg)
669}
670
671#[cfg(test)]
672mod tests {
673 use super::*;
674 use memstead_base::filesystem::config::{WorkspaceConfig, write_workspace_config};
675 use memstead_schema::SchemaRef;
676 use tempfile::TempDir;
677
678 fn write_publishable_workspace(tmp: &TempDir, name: &str) {
679 let memstead_dir = tmp.path().join(".memstead");
682 std::fs::create_dir_all(&memstead_dir).unwrap();
683 std::fs::write(
684 memstead_dir.join("workspace.toml"),
685 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
686 )
687 .unwrap();
688 let pin: SchemaRef = "default@1.0.0".parse().unwrap();
693 let cfg = WorkspaceConfig::new(name, pin);
694 write_workspace_config(tmp.path(), &cfg).unwrap();
695 let cfg_path = tmp.path().join(".memstead").join("config.json");
699 let raw = std::fs::read_to_string(&cfg_path).unwrap();
700 let mut value: serde_json::Value = serde_json::from_str(&raw).unwrap();
701 value["version"] = serde_json::json!("0.1.0");
702 std::fs::write(&cfg_path, serde_json::to_string_pretty(&value).unwrap()).unwrap();
703 }
704
705 async fn spawn_fixture_publish_registry() -> (
710 String,
711 std::sync::Arc<std::sync::Mutex<Vec<u8>>>,
712 tokio::task::JoinHandle<()>,
713 ) {
714 use axum::{Json, Router, extract::State, http::StatusCode, routing::post};
715 use std::sync::{Arc, Mutex};
716
717 let captured: Arc<Mutex<Vec<u8>>> = Arc::new(Mutex::new(Vec::new()));
718 let captured_clone = captured.clone();
719 let app: Router = Router::new()
720 .route(
721 "/api/publish",
722 post(
723 move |State(buf): State<Arc<Mutex<Vec<u8>>>>, body: axum::body::Bytes| async move {
724 *buf.lock().unwrap() = body.to_vec();
725 (
726 StatusCode::OK,
727 Json(serde_json::json!({
728 "ok": true,
729 "scope": "fixture",
730 "name": "demo",
731 "version": "0.1.0",
732 "url": "/v/fixture/demo",
733 })),
734 )
735 },
736 ),
737 )
738 .with_state(captured_clone);
739 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
740 let addr = listener.local_addr().unwrap();
741 let handle = tokio::spawn(async move {
742 axum::serve(listener, app).await.unwrap();
743 });
744 (format!("http://{addr}"), captured, handle)
745 }
746
747 #[tokio::test(flavor = "multi_thread")]
748 async fn publish_assembles_from_workspace_when_no_archive_arg() {
749 let tmp = TempDir::new().unwrap();
750 write_publishable_workspace(&tmp, "demo");
751
752 let (base, captured, handle) = spawn_fixture_publish_registry().await;
753
754 let workspace = tmp.path().to_path_buf();
755 let base_clone = base.clone();
756 let captured_clone = captured.clone();
757 let result = tokio::task::spawn_blocking(move || {
758 let ctx = CliContext {
759 json: false,
760 quiet: false,
761 role: Default::default(),
762 };
763 run_with_root(
764 &ctx,
765 Args {
766 archive: None,
767 mem: None,
768 scope: None,
769 version: None,
770 dry_run: false,
771 redact_anchors: false,
772 token: Some("fixture-token".to_string()),
773 registry: Some(base_clone),
774 },
775 Some(workspace),
776 )?;
777 let body = captured_clone.lock().unwrap().clone();
778 Ok::<Vec<u8>, anyhow::Error>(body)
779 })
780 .await
781 .unwrap();
782 handle.abort();
783 let body = result.unwrap();
784
785 assert!(body.len() > 4);
787 assert_eq!(&body[0..4], b"PK\x03\x04");
788 }
789
790 #[test]
797 fn publish_errors_when_root_lacks_workspace_shape() {
798 let tmp = TempDir::new().unwrap();
799 let ctx = CliContext {
801 json: false,
802 quiet: false,
803 role: Default::default(),
804 };
805 let err = run_with_root(
806 &ctx,
807 Args {
808 archive: None,
809 mem: None,
810 scope: None,
811 version: None,
812 dry_run: false,
813 redact_anchors: false,
814 token: Some("fixture-token".to_string()),
815 registry: Some("http://127.0.0.1:1".to_string()),
816 },
817 Some(tmp.path().to_path_buf()),
818 )
819 .unwrap_err();
820 let msg = err.to_string();
821 assert!(!msg.is_empty(), "marker-less root must error, got: {msg}");
822 }
823
824 #[test]
825 fn publish_mem_flag_routes_through_engine_and_maps_unknown_mem() {
826 let tmp = TempDir::new().unwrap();
836 write_publishable_workspace(&tmp, "demo");
837 let ctx = CliContext {
838 json: false,
839 quiet: false,
840 role: Default::default(),
841 };
842 let err = run_with_root(
843 &ctx,
844 Args {
845 archive: None,
846 mem: Some("nonexistent".to_string()),
847 scope: None,
848 version: None,
849 dry_run: false,
850 redact_anchors: false,
851 token: Some("fixture-token".to_string()),
852 registry: Some("http://127.0.0.1:1".to_string()),
853 },
854 Some(tmp.path().to_path_buf()),
855 )
856 .unwrap_err();
857 let msg = err.to_string();
858 assert!(
859 msg.contains("unknown mem") || msg.contains("nonexistent"),
860 "expected an engine UNKNOWN_MEM error from the --mem path, got: {msg}"
861 );
862 }
863
864 #[test]
865 fn publish_version_without_mem_is_rejected_before_any_io() {
866 let ctx = CliContext {
870 json: false,
871 quiet: false,
872 role: Default::default(),
873 };
874 let err = run(
875 &ctx,
876 Args {
877 archive: None,
878 mem: None,
879 scope: None,
880 version: Some("0.2.0".to_string()),
881 dry_run: false,
882 redact_anchors: false,
883 token: None,
884 registry: Some("http://127.0.0.1:1".to_string()),
885 },
886 )
887 .unwrap_err();
888 let msg = err.to_string();
889 assert!(
890 msg.contains("--version requires --mem"),
891 "expected a --version-requires-mem refusal, got: {msg}"
892 );
893 }
894
895 #[tokio::test(flavor = "multi_thread")]
896 async fn dry_run_posts_nothing() {
897 let tmp = TempDir::new().unwrap();
901 write_publishable_workspace(&tmp, "demo");
902
903 let (base, captured, handle) = spawn_fixture_publish_registry().await;
904
905 let workspace = tmp.path().to_path_buf();
906 let base_clone = base.clone();
907 let result = tokio::task::spawn_blocking(move || {
908 let ctx = CliContext {
909 json: false,
910 quiet: false,
911 role: Default::default(),
912 };
913 run_with_root(
914 &ctx,
915 Args {
916 archive: None,
917 mem: None,
918 scope: None,
919 version: None,
920 dry_run: true,
921 redact_anchors: false,
922 token: None,
923 registry: Some(base_clone),
924 },
925 Some(workspace),
926 )?;
927 Ok::<(), anyhow::Error>(())
928 })
929 .await
930 .unwrap();
931 handle.abort();
932 result.unwrap();
933
934 assert!(
935 captured.lock().unwrap().is_empty(),
936 "dry-run must not POST anything to the registry"
937 );
938 }
939
940 #[test]
947 fn redact_anchors_refuses_prebuilt_archive_path() {
948 let ctx = CliContext {
949 json: false,
950 quiet: false,
951 role: Default::default(),
952 };
953 let err = run_with_root(
954 &ctx,
955 Args {
956 archive: Some(PathBuf::from("/nonexistent/some.mem")),
957 mem: None,
958 scope: None,
959 version: None,
960 dry_run: false,
961 redact_anchors: true,
962 token: Some("fixture-token".to_string()),
963 registry: Some("http://127.0.0.1:1".to_string()),
964 },
965 None,
966 )
967 .unwrap_err();
968 let msg = err.to_string();
969 assert!(
970 msg.contains("--redact-anchors") && msg.contains("baked in"),
971 "typed refusal names the flag and the alternative: {msg}"
972 );
973 }
974
975 #[tokio::test(flavor = "multi_thread")]
979 async fn redacted_publish_posts_sentinel_sidecar() {
980 let tmp = TempDir::new().unwrap();
981 write_publishable_workspace(&tmp, "demo");
982 std::fs::write(
983 tmp.path().join("first.md"),
984 "---\ntype: spec\n---\n# First\n",
985 )
986 .unwrap();
987 let sidecar_path = tmp.path().join(".memstead").join("anchors.json");
988 let local = br#"{"version":1,"entities":{"demo--first":[{"artifact":"src/private.rs","grain":"file","class":"anchored","hash_stability":"stable","hash":"h1"}]}}"#;
989 std::fs::write(&sidecar_path, local).unwrap();
990
991 let (base, captured, handle) = spawn_fixture_publish_registry().await;
992 let workspace = tmp.path().to_path_buf();
993 let base_clone = base.clone();
994 let captured_clone = captured.clone();
995 let body = tokio::task::spawn_blocking(move || {
996 let ctx = CliContext {
997 json: false,
998 quiet: false,
999 role: Default::default(),
1000 };
1001 run_with_root(
1002 &ctx,
1003 Args {
1004 archive: None,
1005 mem: None,
1006 scope: None,
1007 version: None,
1008 dry_run: false,
1009 redact_anchors: true,
1010 token: Some("fixture-token".to_string()),
1011 registry: Some(base_clone),
1012 },
1013 Some(workspace),
1014 )?;
1015 Ok::<Vec<u8>, anyhow::Error>(captured_clone.lock().unwrap().clone())
1016 })
1017 .await
1018 .unwrap()
1019 .unwrap();
1020 handle.abort();
1021
1022 let posted = String::from_utf8_lossy(&body).into_owned();
1023 assert!(
1024 posted.contains("[redacted]"),
1025 "posted package carries the sentinel"
1026 );
1027 assert!(
1028 !posted.contains("src/private.rs"),
1029 "posted package must not carry the artifact path"
1030 );
1031 let after = std::fs::read(&sidecar_path).unwrap();
1033 assert_eq!(after, local, "local sidecar bytes are byte-identical");
1034 }
1035}