@@ -847,6 +847,40 @@ VxCoreError BundledFolderManager::ProcessCopiedFolderTree(const std::string &des
847847 const std::string &assets_folder_name = notebook_->GetConfig ().assets_folder ;
848848 ContentProcessor processor;
849849
850+ // Drop index entries whose content did not make it to the destination. The
851+ // content tree is copied by CopyTreeSkipReparsePoints, which SKIPS symlinks /
852+ // junctions; the config subtree is copied verbatim, so without this pruning a
853+ // symlinked-but-indexed entry would leave the copied vx.json referencing a
854+ // path that does not exist on disk (the phantom state CopyFolder reports
855+ // elsewhere as VXCORE_ERR_NODE_NOT_EXISTS) while still returning VXCORE_OK.
856+ config->files .erase (
857+ std::remove_if (config->files .begin (), config->files .end (),
858+ [&](const FileRecord &file) {
859+ if (PathExists (ConcatenatePaths (content_path, file.name ))) {
860+ return false ;
861+ }
862+ VXCORE_LOG_WARN (
863+ " ProcessCopiedFolderTree: dropping index entry with no copied "
864+ " content: %s/%s" ,
865+ dest_path.c_str (), file.name .c_str ());
866+ return true ;
867+ }),
868+ config->files .end ());
869+
870+ config->folders .erase (
871+ std::remove_if (config->folders .begin (), config->folders .end (),
872+ [&](const std::string &subfolder_name) {
873+ if (IsDirectory (ConcatenatePaths (content_path, subfolder_name))) {
874+ return false ;
875+ }
876+ VXCORE_LOG_WARN (
877+ " ProcessCopiedFolderTree: dropping subfolder entry with no copied "
878+ " content: %s/%s" ,
879+ dest_path.c_str (), subfolder_name.c_str ());
880+ return true ;
881+ }),
882+ config->folders .end ());
883+
850884 // Process each file: regenerate UUID, rename assets dir, rewrite content
851885 for (auto &file : config->files ) {
852886 std::string old_uuid = file.id ;
@@ -987,9 +1021,11 @@ VxCoreError BundledFolderManager::CopyFolder(const std::string &src_path,
9871021 return error;
9881022 }
9891023
990- try {
991- fs::copy (src_content_path_fs, dest_content_path_fs, fs::copy_options::recursive);
992- } catch (const std::exception &) {
1024+ // Recursive copy that never follows symlinks/junctions and never escapes the
1025+ // source tree. fs::copy(recursive) follows reparse points; copy_options::
1026+ // skip_symlinks is also insufficient on MSVC, which classifies junctions as
1027+ // file_type::junction rather than file_type::symlink.
1028+ if (!CopyTreeSkipReparsePoints (src_content_path_fs, dest_content_path_fs)) {
9931029 return VXCORE_ERR_IO ;
9941030 }
9951031
@@ -998,7 +1034,9 @@ VxCoreError BundledFolderManager::CopyFolder(const std::string &src_path,
9981034 try {
9991035 fs::path src_config_dir = PathFromUtf8 (GetConfigPath (clean_src_path)).parent_path ();
10001036 fs::path dest_config_dir = PathFromUtf8 (GetConfigPath (dest_path)).parent_path ();
1001- fs::copy (src_config_dir, dest_config_dir, fs::copy_options::recursive);
1037+ if (!CopyTreeSkipReparsePoints (src_config_dir, dest_config_dir)) {
1038+ return VXCORE_ERR_IO ;
1039+ }
10021040 } catch (const std::exception &) {
10031041 return VXCORE_ERR_IO ;
10041042 }
@@ -2282,28 +2320,24 @@ VxCoreError BundledFolderManager::ImportFolder(const std::string &dest_folder_pa
22822320 return VXCORE_ERR_INVALID_PARAM ;
22832321 }
22842322
2285- // Reject importing a folder from within the notebook root to prevent circular copies
2286- try {
2287- // BOTH sides must be canonicalized before comparing. Canonicalizing only
2288- // the external path made the check silently pass whenever the two spellings
2289- // of the same location differ — e.g. on Windows where the notebook root was
2290- // opened through an 8.3 short path (C:/Users/RUNNER~1/...) while
2291- // fs::canonical resolves the external path to the long form
2292- // (C:/Users/runneradmin/...), or anywhere a symlink is in play.
2293- fs::path canonical_external = fs::weakly_canonical (external_path);
2294- fs::path canonical_root = fs::weakly_canonical (PathFromUtf8 (notebook_->GetRootFolder ()));
2295- // Check if external path starts with (is under) notebook root
2296- auto mismatch_pair = std::mismatch (canonical_root.begin (), canonical_root.end (),
2297- canonical_external.begin (), canonical_external.end ());
2298- if (mismatch_pair.first == canonical_root.end ()) {
2299- // root_path is a prefix of canonical_external (external is inside notebook)
2300- VXCORE_LOG_ERROR (" ImportFolder: Cannot import folder from within notebook root: %s" ,
2301- external_folder_path.c_str ());
2302- return VXCORE_ERR_INVALID_PARAM ;
2303- }
2304- } catch (const std::exception &e) {
2305- // If canonical fails, the path may not exist or be inaccessible
2306- VXCORE_LOG_WARN (" ImportFolder: Failed to canonicalize paths: %s" , e.what ());
2323+ // Reject importing a folder from within the notebook root to prevent circular
2324+ // copies. This fails CLOSED: if containment cannot be verified (permission
2325+ // denied, cyclic link, device error) the import is rejected rather than
2326+ // silently proceeding without a guard.
2327+ if (IsPathWithin (notebook_->GetRootFolder (), external_folder_path, /* on_error=*/ true )) {
2328+ VXCORE_LOG_ERROR (" ImportFolder: Cannot import folder from within notebook root "
2329+ " (or containment could not be verified): %s" ,
2330+ external_folder_path.c_str ());
2331+ return VXCORE_ERR_INVALID_PARAM ;
2332+ }
2333+
2334+ // Canonical source root, captured once, for the per-entry containment check.
2335+ std::error_code src_ec;
2336+ const fs::path canonical_source_root = fs::weakly_canonical (external_path, src_ec);
2337+ if (src_ec) {
2338+ VXCORE_LOG_ERROR (" ImportFolder: Failed to canonicalize external folder: %s (%s)" ,
2339+ external_folder_path.c_str (), src_ec.message ().c_str ());
2340+ return VXCORE_ERR_INVALID_PARAM ;
23072341 }
23082342
23092343 // Parse suffix allowlist into a set of lowercase extensions
@@ -2367,18 +2401,47 @@ VxCoreError BundledFolderManager::ImportFolder(const std::string &dest_folder_pa
23672401 return VXCORE_ERR_ALREADY_EXISTS ;
23682402 }
23692403
2370- // Copy the external folder to notebook folder recursively (with suffix filtering)
2371- std::function<void (const fs::path &, const fs::path &)> copy_filtered =
2372- [&](const fs::path &src, const fs::path &dest) {
2404+ // Copy the external folder to notebook folder recursively (with suffix filtering).
2405+ // Returns false on a hard IO error so the caller can report VXCORE_ERR_IO: an
2406+ // entry that cannot be stat'ed is NOT silently dropped, which would turn a
2407+ // truncated import into a VXCORE_OK.
2408+ //
2409+ // TOCTOU: these are path-based check-then-recurse guards. A source tree
2410+ // mutated concurrently by an attacker can still race between the check and
2411+ // the copy; closing that would need handle-relative / no-follow traversal,
2412+ // which is not attempted here.
2413+ std::function<bool (const fs::path &, const fs::path &)> copy_filtered =
2414+ [&](const fs::path &src, const fs::path &dest) -> bool {
23732415 fs::create_directories (dest);
23742416 for (const auto &entry : fs::directory_iterator (src)) {
23752417 const std::string entry_name = PathToUtf8 (entry.path ().filename ());
23762418 // Skip hidden files/folders
23772419 if (entry_name.empty () || entry_name[0 ] == ' .' ) {
23782420 continue ;
23792421 }
2422+ const std::string entry_utf8 = PathToUtf8 (entry.path ());
2423+ // Primary defense: never follow symlinks/junctions/other reparse
2424+ // points, for files as well as directories.
2425+ const ReparseState reparse_state = CheckReparsePoint (entry_utf8);
2426+ if (reparse_state == ReparseState::kError ) {
2427+ VXCORE_LOG_ERROR (" ImportFolder: Failed to stat entry: %s" , entry_utf8.c_str ());
2428+ return false ;
2429+ }
2430+ if (reparse_state == ReparseState::kYes ) {
2431+ VXCORE_LOG_WARN (" ImportFolder: Skipping symlink/junction/reparse point: %s" ,
2432+ entry_utf8.c_str ());
2433+ continue ;
2434+ }
23802435 if (entry.is_directory ()) {
2381- copy_filtered (entry.path (), dest / PathFromUtf8 (entry_name));
2436+ // Defense in depth: cannot verify containment -> outside -> skip.
2437+ if (!IsPathWithinCanonical (canonical_source_root, entry_utf8, /* on_error=*/ false )) {
2438+ VXCORE_LOG_WARN (" ImportFolder: Skipping subdirectory outside source root: %s" ,
2439+ entry_utf8.c_str ());
2440+ continue ;
2441+ }
2442+ if (!copy_filtered (entry.path (), dest / PathFromUtf8 (entry_name))) {
2443+ return false ;
2444+ }
23822445 } else if (entry.is_regular_file ()) {
23832446 // Apply suffix filter if allowlist is specified
23842447 if (!allowed_suffixes.empty ()) {
@@ -2396,10 +2459,16 @@ VxCoreError BundledFolderManager::ImportFolder(const std::string &dest_folder_pa
23962459 fs::copy_options::overwrite_existing);
23972460 }
23982461 }
2462+ return true ;
23992463 };
24002464
24012465 try {
2402- copy_filtered (external_path, target_path);
2466+ if (!copy_filtered (external_path, target_path)) {
2467+ VXCORE_LOG_ERROR (" ImportFolder: Failed to copy folder: source entry unreadable" );
2468+ std::error_code rollback_ec;
2469+ fs::remove_all (target_path, rollback_ec);
2470+ return VXCORE_ERR_IO ;
2471+ }
24032472 VXCORE_LOG_DEBUG (" ImportFolder: Copied folder to: %s" , PathToUtf8 (target_path).c_str ());
24042473 } catch (const std::exception &e) {
24052474 VXCORE_LOG_ERROR (" ImportFolder: Failed to copy folder: %s" , e.what ());
0 commit comments