Merge pull request #3321 from dotCooCoo/fix/links-on-another-mapped-share-are-not-followed

Links: a link on a drive mapped to another share is not followed
This commit is contained in:
BFlat
2026-10-21 16:11:54 +07:00
committed by GitHub
4 changed files with 164 additions and 5 deletions
+66 -2
View File
@@ -1153,9 +1153,11 @@ pub(crate) fn absolute_path(path: &str) -> String {
/// path that is still relative (no document) is refused, as the working directory isn't the
/// document's choice. On Windows, a document on a mapped drive is on the share behind the drive
/// ([`share_path`]): a path refused as on another computer is checked again against the
/// document's path on that share. Every stored path reaches the file system through this.
/// document's path on that share. A path on a drive mapped to a network share other than the
/// document's ([`drive_share_path`]) is refused as on another computer. Every stored path
/// reaches the file system through this.
pub(crate) fn document_path(stored: &str, document: Option<&str>) -> std::result::Result<String, craft_docpath::Refused> {
document_path_with(stored, document, share_path)
document_path_on(stored, document, share_path, drive_share_path)
}
/// [`document_path`], with `share` giving a document's path on the network share behind its drive.
@@ -1180,6 +1182,68 @@ pub(crate) fn document_path_with(
path.into_os_string().into_string().map_err(|_| Refused::Invalid)
}
/// [`document_path_with`], also refusing a path on a drive that is mapped to a network share other
/// than the document's (or, for a document on this computer or without a path, to any share) as
/// [`craft_docpath::Refused::Network`]. `drive_share` gives a path's path on the share behind its
/// drive (`Y:\art\x.png` on `\\server\other\art\x.png`), or `None` for a path on this computer. A
/// path on the document's own drive is not looked up. A document written `\\?\Z:\…` is looked up
/// on its share as `Z:\…` is.
pub(crate) fn document_path_on(
stored: &str,
document: Option<&str>,
share: impl Fn(&str) -> Option<String>,
drive_share: impl FnOnce(&str) -> Option<String>,
) -> std::result::Result<String, craft_docpath::Refused> {
let share = |d: &str| share(d.strip_prefix(r"\\?\").filter(|d| drive_letter(d).is_some()).unwrap_or(d));
let path = document_path_with(stored, document, share)?;
let document = document.map(absolute_path);
let Some(drive) = drive_letter(&path) else { return Ok(path) };
if document.as_deref().and_then(drive_letter).is_some_and(|d| d.eq_ignore_ascii_case(&drive)) {
return Ok(path);
}
let Some(on_share) = drive_share(&path) else { return Ok(path) };
let home = document.map(|d| share(&d).unwrap_or(d));
craft_docpath::resolve(&on_share, home.as_deref().map(std::path::Path::new))?;
Ok(path)
}
/// The drive letter of an absolute path on a drive (`C:\…`, `C:/…` or `\\?\C:\…`).
fn drive_letter(path: &str) -> Option<char> {
let mut chars = path.strip_prefix(r"\\?\").unwrap_or(path).chars();
match (chars.next(), chars.next(), chars.next()) {
(Some(l), Some(':'), Some('\\' | '/')) if l.is_ascii_alphabetic() => Some(l),
_ => None,
}
}
/// The path of `path` (on a drive, `Y:\art\x.png`) on the network share the drive is mapped to
/// (`\\server\other\art\x.png`), when the drive's root folder is on one and automation may read
/// `path`. The root folder's final path ([`std::fs::canonicalize`]) names the share.
#[cfg(windows)]
pub(crate) fn drive_share_path(path: &str) -> Option<String> {
may_read(path).ok()?;
drive_share_of(path, |root| std::fs::canonicalize(root).ok())
}
#[cfg(not(windows))]
pub(crate) fn drive_share_path(_: &str) -> Option<String> {
None
}
/// The path of `path` on the network share its drive is mapped to, given `final_path`, which
/// returns the final path of the drive's root folder (`Y:\`): as [`share_of`] gives it for a
/// document, also for a path written `\\?\Y:\…`.
#[cfg(any(windows, test))]
pub(crate) fn drive_share_of(path: &str, final_path: impl FnOnce(&str) -> Option<std::path::PathBuf>) -> Option<String> {
let path = path.strip_prefix(r"\\?\").unwrap_or(path);
share_of(path, |folder| {
let (root, rest) = (folder.get(..3)?, folder.get(3..)?);
let root = final_path(root)?;
let root = root.to_str()?.trim_end_matches('\\');
Some(if rest.is_empty() { root.into() } else { format!(r"{root}\{rest}").into() })
})
}
/// The path of `document` (on a drive, `Z:\jobs\doc.vectorcraft`) on the network share the drive is
/// mapped to (`\\server\share\jobs\doc.vectorcraft`), when its folder is on one and automation may
/// read the folder. The folder's final path ([`std::fs::canonicalize`]) names the share as
+95 -2
View File
@@ -136,6 +136,46 @@ fn a_document_on_a_mapped_drive_is_on_the_share_behind_it() {
}
}
/// The final path Windows gives a folder on a drive: `Z:` and `V:` are mapped to `\\server\share`,
/// `Y:` is mapped to `\\server\other` and `W:` is mapped to `\\far\jobs`; `C:` and `D:` are volumes
/// of this computer.
fn mapped(folder: &str) -> Option<std::path::PathBuf> {
let root = match folder.get(..1)?.to_ascii_uppercase().as_str() {
"Z" | "V" => r"\\?\UNC\server\share",
"Y" => r"\\?\UNC\server\other",
"W" => r"\\?\UNC\far\jobs",
"C" => r"\\?\C:",
"D" => r"\\?\D:",
_ => return None,
};
let rest = folder.get(2..)?.trim_start_matches(['\\', '/']).replace('/', "\\");
Some(if rest.is_empty() { root.into() } else { format!(r"{root}\{rest}").into() })
}
#[test]
fn a_path_on_a_mapped_drive_is_on_the_share_behind_it() {
use crate::cmd::fileio::drive_share_of;
// Only the drive's root folder is looked up, so a path in a folder that doesn't exist still gets
// its path on the share.
let mut asked = vec![];
let mut ask = |root: &str| {
asked.push(root.to_string());
mapped(root)
};
assert_eq!(drive_share_of(r"Y:\art\gone\x.png", &mut ask).as_deref(), Some(r"\\server\other\art\gone\x.png"));
assert_eq!(drive_share_of(r"Y:\x.png", &mut ask).as_deref(), Some(r"\\server\other\x.png"));
assert_eq!(drive_share_of(r"\\?\Y:\x.png", &mut ask).as_deref(), Some(r"\\server\other\x.png"));
assert_eq!(drive_share_of("y:/art/x.png", &mut ask).as_deref(), Some(r"\\server\other\art\x.png"));
assert_eq!(asked, [r"Y:\", r"Y:\", r"Y:\", "y:/"]);
// A volume of this computer, and a drive that can't be opened, give no path on a share.
assert_eq!(drive_share_of(r"D:\art\x.png", mapped), None);
assert_eq!(drive_share_of(r"Q:\art\x.png", mapped), None);
// UNC, root-relative, relative and drive-relative paths, and a drive's root, are not looked up.
for path in [r"\\server\other\x.png", r"\\?\UNC\server\other\x.png", "/Users/a/x.png", "x.png", "Y:x.png", r"Y:\"] {
assert_eq!(drive_share_of(path, |r: &str| panic!("{path}: looked up {r}")), None, "{path}");
}
}
#[cfg(windows)]
#[test]
fn links_on_a_mapped_drives_share_are_followed() {
@@ -155,16 +195,69 @@ fn links_on_a_mapped_drives_share_are_followed() {
assert_eq!(document_path_with(r"D:\art\logo.png", doc, unasked).as_deref(), Ok(r"D:\art\logo.png"));
}
#[cfg(windows)]
#[test]
fn links_on_a_drive_mapped_to_another_share_are_not_followed() {
use crate::cmd::fileio::{document_path_on, drive_share_of, share_of};
let share = |d: &str| share_of(d, mapped);
let drive = |p: &str| drive_share_of(p, mapped);
let refused = Err(craft_docpath::Refused::Network);
// From a document on this computer, a drive mapped to any share is another computer.
let local = Some(r"C:\art\doc.vectorcraft");
for link in [r"Y:\img\logo.png", r"Z:\logo.png", r"\\?\Y:\logo.png", "file:///Y:/logo.png", r"Y:\gone\logo.png"] {
assert_eq!(document_path_on(link, local, share, drive), refused, "{link}");
}
// Links on a mapped drive are refused from a document on a third share, given by its drive or
// by its share, and from a document without a path.
for doc in [Some(r"W:\jobs\doc.vectorcraft"), Some(r"\\far\jobs\doc.vectorcraft"), None] {
assert_eq!(document_path_on(r"Y:\img\logo.png", doc, share, drive), refused, "{doc:?}");
assert_eq!(document_path_on(r"Z:\img\logo.png", doc, share, drive), refused, "{doc:?}");
}
}
#[cfg(windows)]
#[test]
fn links_on_the_documents_share_or_this_computer_are_followed() {
use crate::cmd::fileio::{document_path_on, drive_share_of, share_of};
let share = |d: &str| share_of(d, mapped);
let drive = |p: &str| drive_share_of(p, mapped);
// Links on the document's share are followed: through another drive mapped to it, by the share's
// own path, and from the document given by its share.
let doc = Some(r"Z:\jobs\doc.vectorcraft");
assert_eq!(document_path_on(r"V:\img\logo.png", doc, share, drive).as_deref(), Ok(r"V:\img\logo.png"));
let by_share = Some(r"\\server\share\jobs\doc.vectorcraft");
assert_eq!(document_path_on(r"Z:\img\logo.png", by_share, share, drive).as_deref(), Ok(r"Z:\img\logo.png"));
assert_eq!(document_path_on(r"\\server\share\logo.png", doc, share, drive).as_deref(), Ok(r"\\server\share\logo.png"));
// A document written `\\?\Z:\…` is on the same share.
let verbatim = Some(r"\\?\Z:\jobs\doc.vectorcraft");
assert_eq!(document_path_on(r"V:\img\logo.png", verbatim, share, drive).as_deref(), Ok(r"V:\img\logo.png"));
assert_eq!(document_path_on(r"\\server\share\logo.png", verbatim, share, drive).as_deref(), Ok(r"\\server\share\logo.png"));
assert_eq!(document_path_on(r"Y:\img\logo.png", verbatim, share, drive), Err(craft_docpath::Refused::Network));
// Links on this computer and on a drive that can't be opened are followed, from a document
// anywhere.
for doc in [Some(r"C:\art\doc.vectorcraft"), doc, Some(r"\\far\jobs\doc.vectorcraft"), None] {
assert_eq!(document_path_on(r"D:\art\logo.png", doc, share, drive).as_deref(), Ok(r"D:\art\logo.png"), "{doc:?}");
assert_eq!(document_path_on(r"Q:\art\logo.png", doc, share, drive).as_deref(), Ok(r"Q:\art\logo.png"), "{doc:?}");
}
// A path on the document's own drive is not looked up.
let unasked = |p: &str| -> Option<String> { panic!("looked up {p}") };
assert_eq!(document_path_on(r"img\logo.png", doc, share, unasked).as_deref(), Ok(r"Z:\jobs\img\logo.png"));
assert_eq!(document_path_on(r"z:\logo.png", doc, share, unasked).as_deref(), Ok(r"z:\logo.png"));
assert_eq!(document_path_on(r"C:\x\logo.png", Some(r"C:\art\doc.vectorcraft"), share, unasked).as_deref(), Ok(r"C:\x\logo.png"));
}
#[test]
fn a_document_off_a_mapped_drive_keeps_its_path() {
use crate::cmd::fileio::share_path;
use crate::cmd::fileio::{drive_share_path, share_path};
let dir = Folder::new("docpath-share");
// A folder on this computer's own disk, and one that doesn't exist.
assert_eq!(share_path(&dir.file("doc.vectorcraft")), None);
assert_eq!(share_path(&dir.file("gone/doc.vectorcraft")), None);
// Elsewhere than Windows, no document is looked up.
assert_eq!(drive_share_path(&dir.file("gone/x.png")), None);
// Elsewhere than Windows, no document or link is looked up.
if cfg!(not(windows)) {
assert_eq!(share_path(r"Z:\jobs\doc.vectorcraft"), None);
assert_eq!(drive_share_path(r"Y:\img\logo.png"), None);
}
}
+2 -1
View File
@@ -2829,7 +2829,8 @@ the linked files again, looking for each at its path, then at its relative path
(so a folder moved with its links still opens). Only files on this computer, or on the network share the document is
on, are looked at: a path to another computer, a device or a URL counts as missing. On Windows, a document opened from
a mapped drive (`Z:\…`) is on the share the drive is mapped to, and links on that share (`\\server\share\…`) are
looked at. The result lists `missingLinks`
looked at. A link on a drive mapped to another share (`Y:\…`) counts as missing, from a document on this computer too.
The result lists `missingLinks`
(their images show the preview), `modifiedLinks` (left as they were; read again only with the preference
`updateLinks: "automatically"`, then they are in `updatedLinks`), each as `{name, path, ids}`, with `refused` (why the
path isn't followed) when it isn't. The desktop app also checks the active document's links when its window
+1
View File
@@ -761,6 +761,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- On the desktop, Copy and Cut make the system clipboard's formats (text, SVG, PDF, PNG) from a copy of the clipboard on the thread that writes background saves and exports, and write them when they are ready; while that thread has a save, an export, a print or a recovery copy to finish, they are made and written at once. Until they are written, Paste in VectorCraft takes the internal clipboard, and while a text field or the Type tool has the keyboard the app waits for them. Another Copy or Cut, or a copy of text in VectorCraft, drops them.
- Missing linked files can be looked for in a folder and its subfolders: Find in Folder… in the Linked File Not Found dialog and in the Links panel menu lists the files with each one's name, those with the size and contents saved with the link first, and relinks the files chosen, copying an image's file into the document's folder when asked, without replacing a file there (`links.findFiles`, `links.relink {copy}`). In the Links panel it looks for the selected linked files when any are selected.
- On Windows, a document opened from a mapped network drive finds its links stored with the share's own path (`\\server\share\…`), as the same document opened from the share does; links on other computers stay missing.
- On Windows, a link on a drive mapped to a network share other than the document's (`Y:\…`) is missing, with the reason that the path is on another computer, as a link stored with that share's own path is; links on this computer's drives and on the document's share are followed.
- In the Links panel, Shift-click selects a range of links and Ctrl/Cmd-click adds or removes one.
- Linked files another app changes while their document is open follow Preferences › File Handling › Update Links, as they do when it opens: Automatically reads them again (an Update Links undo step), Ask When Modified offers to, Manually leaves them modified in the Links panel. The desktop app compares the active document’s linked files by size and modification time every two seconds and when its window comes back to the front, on a worker thread; a file still being written is tried again (`links.updateChanged`).
- Import and paste, 2026-10-13: a multi-artboard `.ai` with notes beside its artboards opens from its editing data, a page's art past its artboard no longer lying over the page before it (#1528); a `.ai` gradient whose stops are listed upwards keeps each midpoint on the stop that starts its part of the ramp (#1530); SVG text on a path follows basic shapes (circle, ellipse, rect, line, polyline, polygon) as their SVG 2 equivalent paths (#1531, fixes #1527); PDFs whose alpha soft masks are drawn as images import with the mask instead of empty art (#1551); EPS and PostScript type set in Type 42 (TrueType) fonts imports at its size instead of 1000 times too large (#1553); PDF and EPS import keep a clipping path whose bounding box covers the page but whose shape doesn't (#1544, fixes #1542). Opening or placing a file with an image too large to decode says so, and the Links panel shows it on the row (#1540, part of #1479). Paste takes a PDF or EPS another app copied as editable vector art: from the macOS pasteboard (with SVG), EPS on Windows beside PDF, and `.eps` files copied in a file manager, with the new command `clipboard.importEps` (#1538, fixes #1499).