From 53ed4a89acc808f2292bcaceed9e69c30f7df0b1 Mon Sep 17 00:00:00 2001 From: samzong Date: Thu, 13 Aug 2026 23:02:39 -0400 Subject: [PATCH 1/3] fix(share): reject unmanaged publish directories Signed-off-by: samzong --- src/share/publish.rs | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/src/share/publish.rs b/src/share/publish.rs index 577391a..72629f2 100644 --- a/src/share/publish.rs +++ b/src/share/publish.rs @@ -17,6 +17,7 @@ const PROVIDER_CLOUDFLARE_PAGES: &str = "cloudflare-pages"; const PAGES_PROJECT_NAME_FIELD: &str = "Project Name"; const PAGES_PROJECT_DOMAINS_FIELD: &str = "Project Domains"; const MAX_PAGES_ASSET_BYTES: usize = 25 * 1024 * 1024; +const PUBLISH_DIR_MARKER: &str = ".recall-share"; #[derive(Debug, Clone)] pub(crate) struct SharePreview { @@ -237,6 +238,18 @@ fn configured_project_domain(share: &ShareConfig) -> Result { fn init_publish_dir(publish_dir: &Path) -> Result<()> { fs::create_dir_all(publish_dir) .with_context(|| format!("failed to create {}", publish_dir.display()))?; + let marker = publish_dir.join(PUBLISH_DIR_MARKER); + if !marker.is_file() { + let mut entries = fs::read_dir(publish_dir) + .with_context(|| format!("failed to read {}", publish_dir.display()))?; + if entries.next().transpose()?.is_some() { + bail!( + "publish directory {} is not managed by Recall; choose an empty directory", + publish_dir.display() + ); + } + fs::write(&marker, "Recall-managed share directory\n")?; + } fs::write(publish_dir.join("_headers"), HEADERS)?; fs::write(publish_dir.join("robots.txt"), ROBOTS)?; Ok(()) @@ -429,6 +442,23 @@ mod tests { assert_eq!(share_id_for_session(&session("foo/bar baz")), "foo-bar-baz"); } + #[test] + fn publish_dir_rejects_unmanaged_files() { + let dir = tempfile::tempdir().unwrap(); + fs::write(dir.path().join("canary-secret"), "do not publish").unwrap(); + + let error = init_publish_dir(dir.path()).unwrap_err(); + + assert!(error.to_string().contains("not managed by Recall")); + assert!(!dir.path().join("_headers").exists()); + assert!(!dir.path().join("robots.txt").exists()); + + let managed = tempfile::tempdir().unwrap(); + init_publish_dir(managed.path()).unwrap(); + assert!(managed.path().join(PUBLISH_DIR_MARKER).is_file()); + init_publish_dir(managed.path()).unwrap(); + } + fn session(source_id: &str) -> Session { Session { id: "local-id".to_string(), From 021e77411b8c653622781745ff5fca7fa3e6f1e3 Mon Sep 17 00:00:00 2001 From: samzong Date: Thu, 13 Aug 2026 23:13:53 -0400 Subject: [PATCH 2/3] fix(share): migrate legacy publish directories Signed-off-by: samzong --- src/share/publish.rs | 84 ++++++++++++++++++++++++++++++++++++++------ 1 file changed, 74 insertions(+), 10 deletions(-) diff --git a/src/share/publish.rs b/src/share/publish.rs index 72629f2..7470c3b 100644 --- a/src/share/publish.rs +++ b/src/share/publish.rs @@ -18,6 +18,14 @@ const PAGES_PROJECT_NAME_FIELD: &str = "Project Name"; const PAGES_PROJECT_DOMAINS_FIELD: &str = "Project Domains"; const MAX_PAGES_ASSET_BYTES: usize = 25 * 1024 * 1024; const PUBLISH_DIR_MARKER: &str = ".recall-share"; +const PUBLISH_DIR_MARKER_CONTENT: &str = "Recall-managed share directory\n"; +const LEGACY_HEADERS: &str = "/*\n X-Robots-Tag: noindex, nofollow\n X-Frame-Options: DENY\n X-Content-Type-Options: nosniff\n Referrer-Policy: no-referrer\n"; +const SHARE_HTML_PREFIX: &str = ""; +const SHARE_HTML_SIGNATURES: [&str; 3] = [ + "body{margin:0;background:#f6f7f9;color:#17181c;font:15px/1.6", + "--page-bg: #F5F5F7;\n --content-bg: #FFFFFF;", + "--page-bg:#FAF9F6;--surface:#FFFFFF;--user-surface:#FFFFFF;", +]; #[derive(Debug, Clone)] pub(crate) struct SharePreview { @@ -239,22 +247,61 @@ fn init_publish_dir(publish_dir: &Path) -> Result<()> { fs::create_dir_all(publish_dir) .with_context(|| format!("failed to create {}", publish_dir.display()))?; let marker = publish_dir.join(PUBLISH_DIR_MARKER); - if !marker.is_file() { - let mut entries = fs::read_dir(publish_dir) - .with_context(|| format!("failed to read {}", publish_dir.display()))?; - if entries.next().transpose()?.is_some() { - bail!( - "publish directory {} is not managed by Recall; choose an empty directory", - publish_dir.display() - ); - } - fs::write(&marker, "Recall-managed share directory\n")?; + let managed = + fs::read_to_string(&marker).is_ok_and(|contents| contents == PUBLISH_DIR_MARKER_CONTENT); + if !managed && publish_dir_is_unmanaged(publish_dir)? { + bail!( + "publish directory {} is not managed by Recall; choose an empty directory", + publish_dir.display() + ); + } + if !managed { + fs::write(&marker, PUBLISH_DIR_MARKER_CONTENT)?; } fs::write(publish_dir.join("_headers"), HEADERS)?; fs::write(publish_dir.join("robots.txt"), ROBOTS)?; Ok(()) } +fn publish_dir_is_unmanaged(publish_dir: &Path) -> Result { + let mut has_entries = false; + let mut has_headers = false; + let mut has_robots = false; + for entry in fs::read_dir(publish_dir)? { + let entry = entry?; + if !entry.file_type()?.is_file() { + return Ok(true); + } + has_entries = true; + match entry.file_name().to_str() { + Some("_headers") => { + let contents = fs::read_to_string(entry.path())?; + if contents != HEADERS && contents != LEGACY_HEADERS { + return Ok(true); + } + has_headers = true; + } + Some("robots.txt") => { + if fs::read_to_string(entry.path())? != ROBOTS { + return Ok(true); + } + has_robots = true; + } + Some(name) if name.ends_with(".html") => { + let html = fs::read_to_string(entry.path())?; + if !html.starts_with(SHARE_HTML_PREFIX) + || !SHARE_HTML_SIGNATURES.iter().any(|signature| html.contains(signature)) + || !html.ends_with("") + { + return Ok(true); + } + } + _ => return Ok(true), + } + } + Ok(has_entries && !(has_headers && has_robots)) +} + fn ensure_wrangler_available() -> Result<()> { let output = wrangler_command()? .arg("--version") @@ -453,10 +500,27 @@ mod tests { assert!(!dir.path().join("_headers").exists()); assert!(!dir.path().join("robots.txt").exists()); + let spoofed = tempfile::tempdir().unwrap(); + fs::write(spoofed.path().join(PUBLISH_DIR_MARKER), "not Recall").unwrap(); + assert!(init_publish_dir(spoofed.path()).is_err()); + let managed = tempfile::tempdir().unwrap(); init_publish_dir(managed.path()).unwrap(); assert!(managed.path().join(PUBLISH_DIR_MARKER).is_file()); init_publish_dir(managed.path()).unwrap(); + + let legacy = tempfile::tempdir().unwrap(); + fs::write(legacy.path().join("_headers"), HEADERS).unwrap(); + fs::write(legacy.path().join("robots.txt"), ROBOTS).unwrap(); + let session = session("legacy-session"); + let meta = collect_session_display_meta(&session, &[]); + fs::write( + legacy.path().join("legacy-session.html"), + render_session_html(&session, &[], &meta), + ) + .unwrap(); + init_publish_dir(legacy.path()).unwrap(); + assert!(legacy.path().join(PUBLISH_DIR_MARKER).is_file()); } fn session(source_id: &str) -> Session { From 5beff0741ebbd1b0037667e369bf2c20d48b70fb Mon Sep 17 00:00:00 2001 From: samzong Date: Thu, 13 Aug 2026 23:27:20 -0400 Subject: [PATCH 3/3] fix(share): revalidate managed publish directories Signed-off-by: samzong --- src/share/publish.rs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/share/publish.rs b/src/share/publish.rs index 7470c3b..9addce3 100644 --- a/src/share/publish.rs +++ b/src/share/publish.rs @@ -249,7 +249,7 @@ fn init_publish_dir(publish_dir: &Path) -> Result<()> { let marker = publish_dir.join(PUBLISH_DIR_MARKER); let managed = fs::read_to_string(&marker).is_ok_and(|contents| contents == PUBLISH_DIR_MARKER_CONTENT); - if !managed && publish_dir_is_unmanaged(publish_dir)? { + if publish_dir_is_unmanaged(publish_dir, managed)? { bail!( "publish directory {} is not managed by Recall; choose an empty directory", publish_dir.display() @@ -263,7 +263,7 @@ fn init_publish_dir(publish_dir: &Path) -> Result<()> { Ok(()) } -fn publish_dir_is_unmanaged(publish_dir: &Path) -> Result { +fn publish_dir_is_unmanaged(publish_dir: &Path, managed: bool) -> Result { let mut has_entries = false; let mut has_headers = false; let mut has_robots = false; @@ -274,6 +274,7 @@ fn publish_dir_is_unmanaged(publish_dir: &Path) -> Result { } has_entries = true; match entry.file_name().to_str() { + Some(PUBLISH_DIR_MARKER) if managed => {} Some("_headers") => { let contents = fs::read_to_string(entry.path())?; if contents != HEADERS && contents != LEGACY_HEADERS { @@ -299,7 +300,7 @@ fn publish_dir_is_unmanaged(publish_dir: &Path) -> Result { _ => return Ok(true), } } - Ok(has_entries && !(has_headers && has_robots)) + Ok(!managed && has_entries && !(has_headers && has_robots)) } fn ensure_wrangler_available() -> Result<()> { @@ -508,6 +509,8 @@ mod tests { init_publish_dir(managed.path()).unwrap(); assert!(managed.path().join(PUBLISH_DIR_MARKER).is_file()); init_publish_dir(managed.path()).unwrap(); + fs::write(managed.path().join("later-canary"), "do not publish").unwrap(); + assert!(init_publish_dir(managed.path()).is_err()); let legacy = tempfile::tempdir().unwrap(); fs::write(legacy.path().join("_headers"), HEADERS).unwrap(); @@ -521,6 +524,7 @@ mod tests { .unwrap(); init_publish_dir(legacy.path()).unwrap(); assert!(legacy.path().join(PUBLISH_DIR_MARKER).is_file()); + init_publish_dir(legacy.path()).unwrap(); } fn session(source_id: &str) -> Session {