diff --git a/crates/catalog/src/safe_file.rs b/crates/catalog/src/safe_file.rs
--- a/crates/catalog/src/safe_file.rs
+++ b/crates/catalog/src/safe_file.rs
@@ -22,6 +22,8 @@
 //!   and sync, plus a read-back check, and the file then appears under its final name **without
 //!   replacing anything** (a hard link, which fails on a taken name; where the volume has no hard
 //!   links, an exclusive create that claims the name, then a rename over that placeholder).
+//!   [`publish_new`] is that last step on its own, for a writer with its own temp file (the
+//!   Lightroom recovery archive).
 //! - [`same_file`] tells whether two paths name the same file on disk (device + inode on Unix,
 //!   resolved paths elsewhere), for "is this target an original?" checks.
 
@@ -34,6 +36,7 @@ use std::sync::atomic::{AtomicU64, Ordering};
 thread_local! {
     static FAIL_AFTER: Cell<Option<u64>> = const { Cell::new(None) };
     static SYNCS: Cell<u64> = const { Cell::new(0) };
+    static NO_HARD_LINKS: Cell<bool> = const { Cell::new(false) };
 }
 
 /// How many file and folder syncs this module made **on the calling thread** (for tests: a
@@ -67,6 +70,28 @@ impl Drop for FaultGuard {
     }
 }
 
+/// Fault injection for tests (in any crate): while the returned guard lives, [`publish_new`]
+/// **on the calling thread** acts as on a volume without hard links (FAT/exFAT, some shares), so
+/// it takes the exclusive-create + rename fallback. Product code never calls it.
+#[doc(hidden)]
+pub fn without_hard_links() -> NoHardLinksGuard {
+    NoHardLinksGuard { previous: NO_HARD_LINKS.with(|c| c.replace(true)), _same_thread: std::marker::PhantomData }
+}
+
+/// Ends a [`without_hard_links`] when dropped, putting back what was set before (so nested guards
+/// work). It stays on the thread whose flag it set (not `Send`).
+#[doc(hidden)]
+pub struct NoHardLinksGuard {
+    previous: bool,
+    _same_thread: std::marker::PhantomData<*const ()>,
+}
+
+impl Drop for NoHardLinksGuard {
+    fn drop(&mut self) {
+        NO_HARD_LINKS.with(|c| c.set(self.previous));
+    }
+}
+
 /// Counts what is written; fails past an injected limit.
 struct Limited<W> {
     inner: W,
@@ -221,9 +246,12 @@ pub fn write_new_unique(candidates: &mut dyn Iterator<Item = PathBuf>, data: &[u
     r
 }
 
-/// Give the complete temp file `tmp` the name `path`, unless that is taken.
-fn publish_new(tmp: &Path, path: &Path) -> io::Result<()> {
-    match fs::hard_link(tmp, path) {
+/// Give the complete temp file `tmp` (in the same folder) the name `path`, unless that is taken
+/// (`AlreadyExists`): a hard link, or where the volume has none, [`publish_new_by_rename`]. After
+/// a hard link `tmp` is a second name of the file; after the fallback it is gone.
+pub fn publish_new(tmp: &Path, path: &Path) -> io::Result<()> {
+    let linked = if NO_HARD_LINKS.with(Cell::get) { Err(io::Error::from(io::ErrorKind::Unsupported)) } else { fs::hard_link(tmp, path) };
+    match linked {
         Ok(()) => Ok(()),
         Err(e) if e.kind() == io::ErrorKind::AlreadyExists => Err(e),
         // no hard links on this volume (FAT/exFAT, some shares)
@@ -234,6 +262,10 @@ fn publish_new(tmp: &Path, path: &Path) -> io::Result<()> {
 /// Fallback of [`publish_new`]: reserve the name with an exclusive create (atomic, so a file that
 /// appears at the same time is never replaced), then rename the complete temp file over our own
 /// empty placeholder. `before_claim` runs just before the name is claimed (a test hook).
+///
+/// That guarantee holds against other writers that also create names exclusively (as everything in
+/// a library folder does). A process that deletes or replaces our placeholder between the claim and
+/// the rename would have its file replaced, and a failed rename removes whatever then has the name.
 fn publish_new_by_rename(tmp: &Path, path: &Path, before_claim: &dyn Fn()) -> io::Result<()> {
     before_claim();
     OpenOptions::new().write(true).create_new(true).open(path)?;
diff --git a/crates/engine/src/lightroom_archive.rs b/crates/engine/src/lightroom_archive.rs
--- a/crates/engine/src/lightroom_archive.rs
+++ b/crates/engine/src/lightroom_archive.rs
@@ -36,6 +36,22 @@ const NAME_SUFFIX: &str = ".lca";
 const MAX_SCAN: usize = 1024;
 const IO_BUFFER: usize = 16 * 1024;
 
+/// A saved archive. Once it is published it is complete, so cleanup that fails afterwards (a
+/// temporary left behind, an old archive that could not be retired) is reported beside it, not
+/// as a failure to save it.
+#[derive(Debug, PartialEq, Eq)]
+pub struct Stored {
+    pub path: PathBuf,
+    /// What could not be cleaned up after the archive was published, if anything.
+    pub cleanup_failed: Option<String>,
+}
+
+impl Stored {
+    fn new(path: PathBuf, problems: Vec<String>) -> Self {
+        Self { path, cleanup_failed: (!problems.is_empty()).then(|| problems.join("; ")) }
+    }
+}
+
 /// Store compact source data below `directory` and return its managed archive.
 ///
 /// The operation is safe to call before catalog mutation.  It rejects a
@@ -44,7 +60,7 @@ const IO_BUFFER: usize = 16 * 1024;
 /// most [`MAX_MANAGED_ARCHIVES`] and [`MAX_TOTAL_ARCHIVE_BYTES`].  Legacy
 /// `lightroom-import-*.json` files are deliberately ignored and preserved for
 /// an explicit caller warning.
-pub fn store(directory: &Path, data: &CatalogImport) -> Result<PathBuf, String> {
+pub fn store(directory: &Path, data: &CatalogImport) -> Result<Stored, String> {
     prepare_directory(directory)?;
     let mut managed = managed_files(directory)?;
     managed.sort_by_key(|entry| entry.index);
@@ -64,11 +80,17 @@ pub fn store(directory: &Path, data: &CatalogImport) -> Result<PathBuf, String>
             }
         };
         if same {
-            remove_managed_file(&temporary).map_err(|e| e.to_string())?;
+            // the matching archive already exists: what is left is cleanup
+            let mut problems = Vec::new();
+            if let Err(error) = remove_managed_file(&temporary) {
+                problems.push(format!("could not remove the temporary {}: {error}", temporary.display()));
+            }
             let matched = entry.path.clone();
             let mut all = managed.clone();
-            prune_existing(directory, &mut all, Some(&matched))?;
-            return Ok(matched);
+            if let Err(error) = prune_existing(directory, &mut all, Some(&matched)) {
+                problems.push(error);
+            }
+            return Ok(Stored::new(matched, problems));
         }
     }
 
@@ -115,22 +137,33 @@ pub fn store(directory: &Path, data: &CatalogImport) -> Result<PathBuf, String>
         let _ = remove_managed_file(&temporary);
         return Err(error.to_string());
     }
-    if let Err(error) = remove_managed_file(&temporary) {
-        return Err(format!("archive published but temporary cleanup failed: {error}"));
+    Ok(Stored::new(destination, clean_up_after_publish(&temporary, &quarantined)))
+}
+
+/// Remove the temporary and the quarantined old archives once the new archive is published, and
+/// say what could not be removed. Every removal is tried; none of them can unpublish the archive.
+fn clean_up_after_publish(temporary: &Path, quarantined: &[(PathBuf, PathBuf)]) -> Vec<String> {
+    let mut problems = Vec::new();
+    // after a hard link the temporary is a second name of the archive; after the rename fallback it is gone
+    match remove_managed_file(temporary) {
+        Err(error) if error.kind() != io::ErrorKind::NotFound => {
+            problems.push(format!("could not remove the temporary {}: {error}", temporary.display()));
+        }
+        _ => {}
     }
     for (old, held) in quarantined {
-        if let Err(error) = remove_managed_file(&held) {
-            return Err(format!("archive published but could not retire {}: {error}", old.display()));
+        if let Err(error) = remove_managed_file(held) {
+            problems.push(format!("could not retire {} (left as {}): {error}", old.display(), held.display()));
         }
     }
-    Ok(destination)
+    problems
 }
 
 /// Store source data when possible. An archive that reaches its bounded size is
 /// omitted with a warning so catalog import can still complete.
-pub fn store_best_effort(directory: &Path, data: &CatalogImport) -> Result<Option<PathBuf>, String> {
+pub fn store_best_effort(directory: &Path, data: &CatalogImport) -> Result<Option<Stored>, String> {
     match store(directory, data) {
-        Ok(path) => Ok(Some(path)),
+        Ok(stored) => Ok(Some(stored)),
         Err(error) if error.contains("Lightroom archive exceeds 32 MiB") => Ok(None),
         Err(error) => Err(error),
     }
@@ -145,19 +178,18 @@ struct Managed {
 
 fn prepare_directory(directory: &Path) -> Result<(), String> {
     match fs::symlink_metadata(directory) {
-        Ok(meta) => {
-            if meta.file_type().is_symlink() || !meta.is_dir() {
-                return Err(format!("archive directory is not a real directory: {}", directory.display()));
-            }
-        }
-        Err(error) if error.kind() == io::ErrorKind::NotFound => {
-            fs::create_dir_all(directory).map_err(|e| e.to_string())?;
-            let meta = fs::symlink_metadata(directory).map_err(|e| e.to_string())?;
-            if meta.file_type().is_symlink() || !meta.is_dir() {
-                return Err(format!("archive directory is not a real directory: {}", directory.display()));
-            }
-        }
-        Err(error) => return Err(error.to_string()),
+        Err(error) if error.kind() == io::ErrorKind::NotFound => fs::create_dir_all(directory).map_err(|e| e.to_string())?,
+        _ => {}
+    }
+    require_real_directory(directory)
+}
+
+/// Refuse `directory` when it is a symlink or not a folder, so nothing written below it lands
+/// outside the library. The archive and the import index (`lightroom_job`) both check it.
+pub(crate) fn require_real_directory(directory: &Path) -> Result<(), String> {
+    let meta = fs::symlink_metadata(directory).map_err(|e| e.to_string())?;
+    if meta.file_type().is_symlink() || !meta.is_dir() {
+        return Err(format!("{} is a link or a file, not a folder", directory.display()));
     }
     Ok(())
 }
@@ -420,11 +452,10 @@ fn rollback_quarantine(moved: &[(PathBuf, PathBuf)]) {
     }
 }
 
+/// Never replaces an existing archive (`AlreadyExists`), also on a volume without hard links.
 fn publish_new(temporary: &Path, destination: &Path) -> io::Result<()> {
-    match fs::hard_link(temporary, destination) {
-        Ok(()) => Ok(()),
-        Err(error) => Err(io::Error::new(error.kind(), format!("cannot atomically publish archive: {error}"))),
-    }
+    lightcraft_catalog::safe_file::publish_new(temporary, destination)
+        .map_err(|error| io::Error::new(error.kind(), format!("cannot publish archive {}: {error}", destination.display())))
 }
 
 fn remove_managed_file(path: &Path) -> io::Result<()> {
@@ -560,8 +591,8 @@ mod tests {
     fn deduplicates_identical_deterministic_archives() {
         let path = tempdir();
         let input = data("catalog.lrcat".into());
-        let first = store(&path, &input).unwrap();
-        let second = store(&path, &input).unwrap();
+        let first = store(&path, &input).unwrap().path;
+        let second = store(&path, &input).unwrap().path;
         assert_eq!(first, second);
         assert_eq!(managed_count(&path), 1);
         fs::remove_dir_all(path).unwrap();
@@ -571,12 +602,12 @@ mod tests {
     fn dedup_returns_archive_that_retention_keeps() {
         let path = tempdir();
         let input = data("catalog.lrcat".into());
-        let first = store(&path, &input).unwrap();
+        let first = store(&path, &input).unwrap().path;
         for index in 1..MAX_MANAGED_ARCHIVES {
             let filler = path.join(format!("lightroom-import-{index}.lca"));
             File::create(filler).unwrap().set_len(19 * 1024 * 1024).unwrap();
         }
-        let reused = store(&path, &input).unwrap();
+        let reused = store(&path, &input).unwrap().path;
         assert_eq!(reused, first);
         assert!(reused.is_file());
         assert!(managed_count(&path) <= MAX_MANAGED_ARCHIVES);
@@ -635,7 +666,7 @@ mod tests {
     #[test]
     fn archive_roundtrips_zlib_payload_and_preserves_numeric_arrays() {
         let path = tempdir();
-        let archive = store(&path, &rich_data()).unwrap();
+        let archive = store(&path, &rich_data()).unwrap().path;
         let bytes = fs::read(archive).unwrap();
         assert!(bytes.starts_with(HEADER));
         let json = miniz_oxide::inflate::decompress_to_vec_zlib_with_limit(&bytes[HEADER.len()..], MAX_ARCHIVE_BYTES as usize).unwrap();
@@ -686,4 +717,63 @@ mod tests {
         assert_eq!(fs::read(&old).unwrap(), b"old archive");
         fs::remove_dir_all(path).unwrap();
     }
+
+    /// Cleanup after publishing tries every removal and reports each failure; it never fails the
+    /// archive, which is already published.
+    #[test]
+    fn cleanup_after_publish_reports_what_it_could_not_remove_and_removes_the_rest() {
+        let path = tempdir();
+        let temporary = path.join(".lightroom-import-new.tmp");
+        fs::write(&temporary, b"second name").unwrap();
+        // a held name that is no longer a regular file can't be removed; the next one still is
+        let (stuck, held_stuck) = (path.join("lightroom-import-0.lca"), path.join(".lightroom-retire-test-0.tmp"));
+        let (gone, held_gone) = (path.join("lightroom-import-1.lca"), path.join(".lightroom-retire-test-1.tmp"));
+        fs::create_dir(&held_stuck).unwrap();
+        fs::write(&held_gone, b"old").unwrap();
+        let problems = clean_up_after_publish(&temporary, &[(stuck.clone(), held_stuck.clone()), (gone, held_gone.clone())]);
+        assert_eq!(problems.len(), 1, "{problems:?}");
+        assert!(problems[0].starts_with(&format!("could not retire {} (left as {})", stuck.display(), held_stuck.display())), "{problems:?}");
+        assert!(!temporary.exists() && !held_gone.exists());
+        let stored = Stored::new(path.join("lightroom-import-2.lca"), problems);
+        assert!(stored.cleanup_failed.is_some_and(|note| note.contains("could not retire")));
+        fs::remove_dir_all(path).unwrap();
+    }
+
+    /// Issue #3114: a library on exFAT (no hard links) aborted every Lightroom import here.
+    #[test]
+    fn publishes_on_a_volume_without_hard_links() {
+        let path = tempdir();
+        let _no_links = lightcraft_catalog::safe_file::without_hard_links();
+        let first = store(&path, &rich_data()).unwrap().path;
+        assert!(fs::read(&first).unwrap().starts_with(HEADER));
+        // retention and dedup still work: a new archive gets the next name, the same data reuses it
+        let second = store(&path, &data("other.lrcat".into())).unwrap().path;
+        assert_ne!(first, second);
+        assert_eq!(store(&path, &rich_data()).unwrap().path, first);
+        let mut names: Vec<_> = fs::read_dir(&path).unwrap().map(|entry| entry.unwrap().file_name()).collect();
+        names.sort();
+        assert_eq!(names, ["lightroom-import-0.lca", "lightroom-import-1.lca"], "no temporary left behind");
+        fs::remove_dir_all(path).unwrap();
+    }
+
+    #[test]
+    fn publishing_without_hard_links_never_replaces_an_existing_archive() {
+        let path = tempdir();
+        let existing = path.join("lightroom-import-0.lca");
+        let temporary = path.join(".lightroom-import-new.tmp");
+        fs::write(&existing, b"old archive").unwrap();
+        fs::write(&temporary, b"new archive").unwrap();
+        let _no_links = lightcraft_catalog::safe_file::without_hard_links();
+        let error = publish_new(&temporary, &existing).unwrap_err();
+        assert_eq!(error.kind(), io::ErrorKind::AlreadyExists);
+        assert!(error.to_string().contains("lightroom-import-0.lca"), "{error}");
+        assert_eq!(fs::read(&existing).unwrap(), b"old archive");
+        assert_eq!(fs::read(&temporary).unwrap(), b"new archive", "the temporary is kept for the caller to remove");
+        // a free name takes the fallback: the temporary is renamed (a hard link would leave it in place)
+        let fresh = path.join("lightroom-import-1.lca");
+        publish_new(&temporary, &fresh).unwrap();
+        assert_eq!(fs::read(&fresh).unwrap(), b"new archive");
+        assert!(!temporary.exists(), "published by rename, not by a hard link");
+        fs::remove_dir_all(path).unwrap();
+    }
 }
diff --git a/crates/engine/src/lightroom_catalog.rs b/crates/engine/src/lightroom_catalog.rs
--- a/crates/engine/src/lightroom_catalog.rs
+++ b/crates/engine/src/lightroom_catalog.rs
@@ -824,7 +824,8 @@ fn apply(s: &mut crate::Session, data: CatalogImport, update_existing: bool) ->
     let archive_dir = s.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.join("Interop"));
     let index_path = archive_dir.as_ref().map(|p| p.join("lightroom-index.json"));
     let mut index = load_index(index_path.as_deref())?;
-    let archive_path = archive_dir.as_ref().map(|dir| crate::lightroom_archive::store(dir, &data).map_err(error)).transpose()?;
+    let archive_path =
+        archive_dir.as_ref().map(|dir| crate::lightroom_archive::store(dir, &data).map(|stored| stored.path).map_err(error)).transpose()?;
     let undo0 = s.undo.len();
     let existing: HashMap<_, _> =
         s.catalog.photos().filter(|p| p.copy_of.is_none()).filter_map(|p| source_path(p).map(|path| (key(path), p.id))).collect();
diff --git a/crates/engine/src/lightroom_catalog/tests.rs b/crates/engine/src/lightroom_catalog/tests.rs
--- a/crates/engine/src/lightroom_catalog/tests.rs
+++ b/crates/engine/src/lightroom_catalog/tests.rs
@@ -442,6 +442,121 @@ fn persistent_reimport_keeps_copy_and_collection_ids_and_capture_time() {
     std::fs::remove_dir_all(dir).unwrap();
 }
 
+/// Issue #3114: a library on exFAT (no hard links) aborted every import when saving the archive.
+#[test]
+fn imports_into_a_library_on_a_volume_without_hard_links() {
+    let dir = std::env::temp_dir().join(format!("lightcraft-lrcat-no-hard-links-{}", std::process::id()));
+    let _ = std::fs::remove_dir_all(&dir);
+    std::fs::create_dir_all(&dir).unwrap();
+    let source = dir.join("source.lrcat");
+    std::fs::write(&source, native_catalog(&photo_tables((dir.join("offline").to_str().unwrap(), "")))).unwrap();
+    let library = dir.join("library");
+    let mut s = crate::Session::new();
+    s.open_library(&library, false).unwrap();
+    let _no_links = lightcraft_catalog::safe_file::without_hard_links();
+    let report = import(&mut s, &source, false).unwrap();
+    assert_eq!(report["imported"], 1);
+    assert_eq!(report["warnings"], json!([]));
+    let archive = Path::new(report["archive"].as_str().unwrap());
+    assert_eq!(archive, library.join("Interop").join("lightroom-import-0.lca"));
+    assert!(std::fs::read(archive).unwrap().starts_with(b"LC-LRARCH"));
+    assert!(library.join("Interop").join("lightroom-index.json").is_file());
+    let mut names: Vec<_> = std::fs::read_dir(library.join("Interop")).unwrap().map(|entry| entry.unwrap().file_name()).collect();
+    names.sort();
+    assert_eq!(names, ["lightroom-import-0.lca", "lightroom-index.json"], "the writability probe leaves nothing behind");
+    s.close_library().unwrap();
+    std::fs::remove_dir_all(dir).unwrap();
+}
+
+/// The recovery archive is best-effort: when it can't be saved (here `Interop` holds more entries
+/// than the archive scans) the import still goes on, with a warning naming the folder.
+#[test]
+fn an_archive_that_cannot_be_saved_warns_and_the_import_proceeds() {
+    let dir = std::env::temp_dir().join(format!("lightcraft-lrcat-archive-refused-{}", std::process::id()));
+    let _ = std::fs::remove_dir_all(&dir);
+    std::fs::create_dir_all(&dir).unwrap();
+    let source = dir.join("source.lrcat");
+    std::fs::write(&source, native_catalog(&photo_tables((dir.join("offline").to_str().unwrap(), "")))).unwrap();
+    let library = dir.join("library");
+    let mut s = crate::Session::new();
+    s.open_library(&library, false).unwrap();
+    let interop = library.join("Interop");
+    std::fs::create_dir_all(&interop).unwrap();
+    // one more entry than the archive scans (`MAX_SCAN`)
+    for i in 0..=1024 {
+        std::fs::write(interop.join(format!("other-{i}")), b"").unwrap();
+    }
+    let report = import(&mut s, &source, false).unwrap();
+    assert_eq!(report["imported"], 1);
+    assert_eq!(report["archive"], Value::Null);
+    let warnings = report["warnings"].as_array().unwrap();
+    assert!(
+        warnings.iter().any(|w| w.as_str().unwrap().starts_with(&format!("Lightroom source archive not saved in {}", interop.display()))),
+        "{warnings:?}"
+    );
+    assert!(interop.join("lightroom-index.json").is_file(), "re-import mapping is still saved");
+    s.close_library().unwrap();
+    std::fs::remove_dir_all(dir).unwrap();
+}
+
+/// A linked `Interop` folder gets neither the archive nor the import index: both would land
+/// outside the library. The import is refused before any photo is imported.
+#[cfg(unix)]
+#[test]
+fn a_linked_interop_folder_gets_neither_the_archive_nor_the_index() {
+    let dir = std::env::temp_dir().join(format!("lightcraft-lrcat-interop-linked-{}", std::process::id()));
+    let _ = std::fs::remove_dir_all(&dir);
+    std::fs::create_dir_all(dir.join("elsewhere")).unwrap();
+    let source = dir.join("source.lrcat");
+    std::fs::write(&source, native_catalog(&photo_tables((dir.join("offline").to_str().unwrap(), "")))).unwrap();
+    let library = dir.join("library");
+    let mut s = crate::Session::new();
+    s.open_library(&library, false).unwrap();
+    let interop = library.join("Interop");
+    std::os::unix::fs::symlink(dir.join("elsewhere"), &interop).unwrap();
+    let e = import(&mut s, &source, false).unwrap_err();
+    assert!(e.to_string().contains(&format!("{} is a link or a file, not a folder", interop.display())), "{e}");
+    assert_eq!(s.catalog.photos().count(), 0, "refused before anything is imported, so a retry can't duplicate");
+    assert_eq!(std::fs::read_dir(dir.join("elsewhere")).unwrap().count(), 0, "nothing is written through the link");
+    s.close_library().unwrap();
+    std::fs::remove_dir_all(dir).unwrap();
+}
+
+/// An `Interop` folder that can't be written to would let the photos be imported and then fail
+/// at the import index, after the import was saved (a retry could duplicate virtual copies). It is
+/// refused before any photo is imported, with an error naming the folder.
+#[cfg(unix)]
+#[test]
+fn an_unwritable_interop_folder_is_refused_before_anything_is_imported() {
+    use std::os::unix::fs::PermissionsExt;
+    let dir = std::env::temp_dir().join(format!("lightcraft-lrcat-interop-readonly-{}", std::process::id()));
+    let _ = std::fs::remove_dir_all(&dir);
+    std::fs::create_dir_all(&dir).unwrap();
+    let source = dir.join("source.lrcat");
+    std::fs::write(&source, native_catalog(&photo_tables((dir.join("offline").to_str().unwrap(), "")))).unwrap();
+    let library = dir.join("library");
+    let mut s = crate::Session::new();
+    s.open_library(&library, false).unwrap();
+    let interop = library.join("Interop");
+    std::fs::create_dir_all(&interop).unwrap();
+    std::fs::set_permissions(&interop, std::fs::Permissions::from_mode(0o555)).unwrap();
+    // root (some containers) ignores folder permissions: there is nothing to refuse
+    if std::fs::write(interop.join("root-check"), b"").is_ok() {
+        std::fs::set_permissions(&interop, std::fs::Permissions::from_mode(0o755)).unwrap();
+        s.close_library().unwrap();
+        std::fs::remove_dir_all(&dir).unwrap();
+        lightcraft_testkit::skip!(System, "folder permissions are not enforced for this user (root)");
+        return;
+    }
+    let e = import(&mut s, &source, false).unwrap_err();
+    std::fs::set_permissions(&interop, std::fs::Permissions::from_mode(0o755)).unwrap();
+    assert!(e.to_string().contains(&format!("the Lightroom import index can't be written in {}", interop.display())), "{e}");
+    assert_eq!(s.catalog.photos().count(), 0, "refused before anything is imported, so a retry can't duplicate");
+    assert_eq!(std::fs::read_dir(&interop).unwrap().count(), 0, "nothing was written");
+    s.close_library().unwrap();
+    std::fs::remove_dir_all(dir).unwrap();
+}
+
 // Construct a small SQLite fixture from public file-format records, never private media.
 fn varint(mut n: u64) -> Vec<u8> {
     let mut out = vec![(n & 127) as u8];
diff --git a/crates/engine/src/lightroom_job.rs b/crates/engine/src/lightroom_job.rs
--- a/crates/engine/src/lightroom_job.rs
+++ b/crates/engine/src/lightroom_job.rs
@@ -30,12 +30,65 @@ fn cancelled() -> crate::EngineError {
     crate::EngineError::Cancelled(error("Lightroom import cancelled").to_string())
 }
 
+/// Save the recovery archive. It is best-effort: nothing reads it back during import, so an archive
+/// that can't be saved costs the archive (with a warning naming the folder), not the import. A
+/// folder that can't be written to at all is refused first, for the import index's sake.
+/// That warning goes first: the result window lists only the first few, and catalog warnings
+/// (one per unreadable record) can be many.
 fn stage_archive(archive_dir: Option<&Path>, data: &mut CatalogImport) -> crate::Result<Option<PathBuf>> {
-    let archive_path = archive_dir.map(|dir| crate::lightroom_archive::store_best_effort(dir, data).map_err(error)).transpose()?.flatten();
-    if archive_dir.is_some() && archive_path.is_none() {
-        data.warnings.push("Lightroom source archive exceeded 32 MiB; archive skipped".into());
+    let Some(dir) = archive_dir else { return Ok(None) };
+    // The import index, unlike the archive, is required, and it is written only after the photos
+    // are imported. Refuse here, before anything changes, a folder it can't be written to (a link
+    // would take it outside the library), rather than import and then fail, where a retry could
+    // duplicate virtual copies.
+    require_index_folder(dir).map_err(|e| crate::EngineError::Other(format!("cannot import into this library: {e}")))?;
+    match crate::lightroom_archive::store_best_effort(dir, data) {
+        Ok(Some(stored)) => {
+            // the archive is saved: the report names it, and the leftover is a warning of its own
+            if let Some(problem) = stored.cleanup_failed {
+                data.warnings.insert(
+                    0,
+                    format!("Lightroom source archive saved as {}, but cleaning up {} failed: {problem}", stored.path.display(), dir.display()),
+                );
+            }
+            Ok(Some(stored.path))
+        }
+        Ok(None) => {
+            data.warnings.insert(0, "Lightroom source archive exceeded 32 MiB; archive skipped".into());
+            Ok(None)
+        }
+        Err(e) => {
+            data.warnings.insert(0, format!("Lightroom source archive not saved in {}: {e}; archive skipped", dir.display()));
+            Ok(None)
+        }
+    }
+}
+
+/// Check that `lightroom-index.json` can be written in `dir` (creating the folder, as the archive
+/// would): a real folder, not a link or a file, in which a file can be created and removed. The
+/// probe is a plain exclusive create, never a hard link, so volumes without them (exFAT, #3114) pass.
+fn require_index_folder(dir: &Path) -> Result<(), String> {
+    if let Err(e) = std::fs::symlink_metadata(dir) {
+        if e.kind() != std::io::ErrorKind::NotFound {
+            return Err(format!("cannot read {}: {e}", dir.display()));
+        }
+        std::fs::create_dir_all(dir).map_err(|e| format!("cannot create the folder {}: {e}", dir.display()))?;
+    }
+    crate::lightroom_archive::require_real_directory(dir)?;
+    let unwritable = |e: std::io::Error| format!("the Lightroom import index can't be written in {} ({e}); make the folder writable", dir.display());
+    let pid = std::process::id();
+    for slot in 0..64 {
+        let probe = dir.join(format!(".lightroom-index-probe-{pid}-{slot}.tmp"));
+        match std::fs::OpenOptions::new().write(true).create_new(true).open(&probe) {
+            Ok(file) => {
+                drop(file);
+                return std::fs::remove_file(&probe).map_err(unwritable);
+            }
+            Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
+            Err(e) => return Err(unwritable(e)),
+        }
     }
-    Ok(archive_path)
+    Err(format!("no free name to check that {} is writable", dir.display()))
 }
 
 /// Memory identity of the library which owns a prepared completion.
@@ -117,7 +170,14 @@ impl LightroomArchiveFinalization {
         if bytes.len() > MAX_INDEX_BYTES {
             return Err(error("Lightroom import index exceeds 16 MiB"));
         }
-        lightcraft_catalog::safe_file::write_atomic(&path, &bytes).map_err(|e| error(e.to_string()))
+        let failed =
+            |e: &dyn std::fmt::Display| crate::EngineError::Other(format!("could not write the Lightroom import index {}: {e}", path.display()));
+        // the archive refuses a linked `Interop` folder; now that its failure no longer stops the
+        // import, the index must refuse it too, or it would be written outside the library
+        if let Some(dir) = path.parent() {
+            crate::lightroom_archive::require_real_directory(dir).map_err(|e| failed(&e))?;
+        }
+        lightcraft_catalog::safe_file::write_atomic(&path, &bytes).map_err(|e| failed(&e))
     }
 
     pub fn path(&self) -> Option<&Path> {
@@ -564,6 +624,88 @@ mod tests {
         std::fs::remove_dir_all(archive_dir).unwrap();
     }
 
+    /// The archive is best-effort: a folder it can't be written to costs the archive, not the import.
+    #[test]
+    fn an_archive_that_cannot_be_written_is_a_warning_naming_its_folder() {
+        let dir = std::env::temp_dir().join(format!("lightcraft-lr-archive-unwritable-{}", std::process::id()));
+        let _ = std::fs::remove_dir_all(&dir);
+        std::fs::create_dir_all(&dir).unwrap();
+        let interop = dir.join("Interop");
+        std::fs::create_dir_all(&interop).unwrap();
+        // one more entry than the archive scans (`MAX_SCAN`)
+        for i in 0..=1024 {
+            std::fs::write(interop.join(format!("other-{i}")), b"").unwrap();
+        }
+        let mut data = empty_data();
+        // more catalog warnings than the result window lists: the archive's must still be seen
+        data.warnings = (0..10).map(|i| format!("record {i}: unreadable XMP")).collect();
+        assert_eq!(stage_archive(Some(&interop), &mut data).unwrap(), None);
+        assert_eq!(data.warnings.len(), 11);
+        assert!(data.warnings[0].starts_with("Lightroom source archive not saved in "), "{}", data.warnings[0]);
+        assert!(data.warnings[0].contains(&*interop.to_string_lossy()), "{}", data.warnings[0]);
+        std::fs::remove_dir_all(dir).unwrap();
+    }
+
+    /// An archive that is saved but whose cleanup fails afterwards (here an old archive can't be
+    /// retired) is still the import's archive: the report names it, and the cleanup is a warning.
+    #[test]
+    fn a_saved_archive_whose_cleanup_fails_is_reported_with_a_warning() {
+        let dir = std::env::temp_dir().join(format!("lightcraft-lr-archive-cleanup-{}", std::process::id()));
+        let _ = std::fs::remove_dir_all(&dir);
+        let interop = dir.join("Interop");
+        std::fs::create_dir_all(&interop).unwrap();
+        let mut data = empty_data();
+        // an older archive, then this catalog's, then enough others to retire the older one
+        std::fs::write(interop.join("lightroom-import-0.lca"), b"older").unwrap();
+        let saved = crate::lightroom_archive::store(&interop, &data).unwrap().path;
+        assert_eq!(saved, interop.join("lightroom-import-1.lca"));
+        for i in 2..=crate::lightroom_archive::MAX_MANAGED_ARCHIVES {
+            std::fs::write(interop.join(format!("lightroom-import-{i}.lca")), format!("other {i}")).unwrap();
+        }
+        // retiring the older archive needs this name, which is taken
+        std::fs::write(interop.join(format!(".lightroom-retire-{}-0.tmp", std::process::id())), b"taken").unwrap();
+        assert_eq!(stage_archive(Some(&interop), &mut data).unwrap(), Some(saved.clone()));
+        assert!(saved.is_file());
+        assert_eq!(data.warnings.len(), 1, "{:?}", data.warnings);
+        let warning = &data.warnings[0];
+        assert!(warning.starts_with(&format!("Lightroom source archive saved as {}, but cleaning up", saved.display())), "{warning}");
+        assert!(warning.contains("retention temporary path already exists"), "{warning}");
+        assert_eq!(std::fs::read(interop.join("lightroom-import-0.lca")).unwrap(), b"older", "the older archive is kept");
+        std::fs::remove_dir_all(dir).unwrap();
+    }
+
+    /// A failed index write is an I/O error naming the file, not "invalid parameters".
+    #[test]
+    fn an_index_write_failure_names_the_index_file() {
+        let path = std::env::temp_dir().join(format!("lightcraft-lr-index-missing-{}", std::process::id())).join("lightroom-index.json");
+        let e = LightroomArchiveFinalization { path: Some(path.clone()), bytes: Some(b"{}".to_vec()) }.finish().unwrap_err();
+        assert!(matches!(e, crate::EngineError::Other(_)), "{e:?}");
+        assert!(e.to_string().starts_with(&format!("could not write the Lightroom import index {}", path.display())), "{e}");
+    }
+
+    /// A linked `Interop` folder (which the archive already refuses) must not receive the index:
+    /// it would land outside the library and could replace a file there.
+    #[cfg(unix)]
+    #[test]
+    fn an_index_write_refuses_a_linked_interop_folder() {
+        let root = std::env::temp_dir().join(format!("lightcraft-lr-index-linked-{}", std::process::id()));
+        let _ = std::fs::remove_dir_all(&root);
+        let (library, outside) = (root.join("library"), root.join("outside"));
+        std::fs::create_dir_all(&library).unwrap();
+        std::fs::create_dir_all(&outside).unwrap();
+        std::fs::write(outside.join("lightroom-index.json"), b"not ours").unwrap();
+        std::os::unix::fs::symlink(&outside, library.join("Interop")).unwrap();
+        let path = library.join("Interop/lightroom-index.json");
+        let e = LightroomArchiveFinalization { path: Some(path), bytes: Some(b"{}".to_vec()) }.finish().unwrap_err();
+        assert!(e.to_string().contains("is a link or a file, not a folder"), "{e}");
+        assert_eq!(std::fs::read(outside.join("lightroom-index.json")).unwrap(), b"not ours");
+        // preparation refuses the link before anything is imported
+        let mut data = empty_data();
+        let e = stage_archive(Some(&library.join("Interop")), &mut data).unwrap_err();
+        assert!(e.to_string().contains("is a link or a file, not a folder"), "{e}");
+        std::fs::remove_dir_all(root).unwrap();
+    }
+
     #[test]
     fn execute_fn_import_does_not_rewrite_source_sidecar() {
         let dir = std::env::temp_dir().join(format!("lightcraft-lr-job-sidecar-{}", std::process::id()));
diff --git a/docs/lightroom-catalog-import.md b/docs/lightroom-catalog-import.md
--- a/docs/lightroom-catalog-import.md
+++ b/docs/lightroom-catalog-import.md
@@ -76,12 +76,21 @@ catalog changes; any saved recovery archives remain available.
 Before changing records, the importer attempts to save source image records, decoded XMP,
 verbatim develop settings, history, snapshots and collection content to compressed
 `Interop/lightroom-import-N.lca` recovery archives in the LightCraft library. Archiving is
-best-effort: a size limit or write failure produces a warning and does not prevent photo import.
+best-effort: a size limit or write failure produces a warning (naming the `Interop` folder) and
+does not prevent photo import. On a volume without hard links (exFAT, FAT, some network shares) an
+archive is published by claiming its name with an exclusive create and renaming over it, so it
+still never replaces an existing archive.
 Unchanged source data reuses its existing archive. Opaque SQL BLOBs are skipped. Each archive is
 capped at 32 MiB (uncompressed and on disk), with at most eight archives and 128 MiB total
-retained. Oldest managed archives are retired; legacy `.json` archives remain untouched.
-`Interop/lightroom-index.json` records imported identities. Source catalogs and originals are
-never overwritten.
+retained. Oldest managed archives are retired; legacy `.json` archives remain untouched. When an
+archive is saved but cleanup afterwards fails (a temporary file or an old archive that can't be
+removed), the report still names the archive and a separate warning names what was left behind.
+`Interop/lightroom-index.json` records imported identities. Unlike the archive it is required, and
+it is written after the photos are imported, so the importer checks its folder first: when
+`Interop` is a link or a file rather than a folder, or a folder in which a file can't be created
+(read-only, no permission), the import is refused before anything changes, with an error naming
+the folder. Nothing lands outside the library, and a retry can't duplicate virtual copies (the index
+write checks the folder again). Source catalogs and originals are never overwritten.
 
 Native import reads referenced database pages on demand (at most 64 KiB per page), rather than
 loading the whole `.lrcat`. Database files are capped at 16 GiB and 16,777,216 pages; the WAL
diff --git a/docs/whats-new.md b/docs/whats-new.md
--- a/docs/whats-new.md
+++ b/docs/whats-new.md
@@ -1,5 +1,15 @@
 # What's new in LightCraft
 
+## 2026-10-26
+
+### Lightroom catalogs import into libraries on exFAT drives
+- Importing a Lightroom catalog into a library on an exFAT or FAT drive (or a network share without hard links) no
+  longer fails with "cannot atomically publish archive: Operation not supported": the recovery archive in the
+  library's `Interop` folder is saved another way that still never replaces an existing archive. When the archive
+  can't be saved at all, the import goes on and warns, naming the folder, as documented. An `Interop` folder that can't
+  be written to at all (or is a link) is refused before any photo is imported, naming it, since the import index must
+  be saved there; a failed import-index write now names the file instead of reporting "invalid parameters".
+
 ## 2026-10-24
 
 ### Relative-date filters and smart albums follow the clock
