fsm-listen-win32: handle shortnames

Teach FSMonitor daemon on Windows to recognize shortname paths as aliases of normal longname paths. FSMonitor clients, such as `git status`, should receive the longname spelling of changed files (when possible). Sometimes we receive FS events using the shortname, such as when a CMD shell runs "RENAME GIT~1 FOO" or "RMDIR GIT~1". The FS notification arrives using whatever combination of long and shortnames were used by the other process. (Shortnames do seem to be case normalized, however.) Use Windows GetLongPathNameW() to try to map the pathname spelling in the notification event into the normalized longname spelling. (This can fail if the file/directory is deleted, moved, or renamed, because we are asking the FS for the mapping in response to the event and after it has already happened, but we try.) Special case the shortname spelling of ".git" to avoid under-reporting these events. Signed-off-by: Jeff Hostetler <jeffhost@microsoft.com> Signed-off-by: Junio C Hamano <gitster@pobox.com>

Jeff Hostetler committed May 26, 2022 at 21:46 UTC 40f865dc025cfb91ee6338f688c06cdc65b53fa0
2 files changed +397 -54
compat/fsmonitor/fsm-listen-win32.c
+332 -54
@@ -25,6 +25,9 @@ struct one_watch
25 DWORD count;
26
27 struct strbuf path;
28 + wchar_t wpath_longname[MAX_PATH + 1];
29 + DWORD wpath_longname_len;
30 +
31 HANDLE hDir;
32 HANDLE hEvent;
33 OVERLAPPED overlapped;
@@ -34,6 +37,21 @@ struct one_watch
37 * need to later call GetOverlappedResult() and possibly CancelIoEx().
38 */
39 BOOL is_active;
40 +
41 + /*
42 + * Are shortnames enabled on the containing drive? This is
43 + * always true for "C:/" drives and usually never true for
44 + * other drives.
45 + *
46 + * We only set this for the worktree because we only need to
47 + * convert shortname paths to longname paths for items we send
48 + * to clients. (We don't care about shortname expansion for
49 + * paths inside a GITDIR because we never send them to
50 + * clients.)
51 + */
52 + BOOL has_shortnames;
53 + BOOL has_tilde;
54 + wchar_t dotgit_shortname[16]; /* for 8.3 name */
55 };
56
57 struct fsmonitor_daemon_backend_data
@@ -51,17 +69,18 @@ struct fsmonitor_daemon_backend_data
69 };
70
71 /*
54 - * Convert the WCHAR path from the notification into UTF8 and
55 - * then normalize it.
72 + * Convert the WCHAR path from the event into UTF8 and normalize it.
73 + *
74 + * `wpath_len` is in WCHARS not bytes.
75 */
57 -static int normalize_path_in_utf8(FILE_NOTIFY_INFORMATION *info,
76 +static int normalize_path_in_utf8(wchar_t *wpath, DWORD wpath_len,
77 struct strbuf *normalized_path)
78 {
79 int reserve;
80 int len = 0;
81
82 strbuf_reset(normalized_path);
64 - if (!info->FileNameLength)
83 + if (!wpath_len)
84 goto normalize;
85
86 /*
@@ -70,12 +89,12 @@ static int normalize_path_in_utf8(FILE_NOTIFY_INFORMATION *info,
89 * sequence of 2 UTF8 characters. That should let us
90 * avoid ERROR_INSUFFICIENT_BUFFER 99.9+% of the time.
91 */
73 - reserve = info->FileNameLength + 1;
92 + reserve = 2 * wpath_len + 1;
93 strbuf_grow(normalized_path, reserve);
94
95 for (;;) {
77 - len = WideCharToMultiByte(CP_UTF8, 0, info->FileName,
78 - info->FileNameLength / sizeof(WCHAR),
96 + len = WideCharToMultiByte(CP_UTF8, 0,
97 + wpath, wpath_len,
98 normalized_path->buf,
99 strbuf_avail(normalized_path) - 1,
100 NULL, NULL);
@@ -83,9 +102,7 @@ static int normalize_path_in_utf8(FILE_NOTIFY_INFORMATION *info,
102 goto normalize;
103 if (GetLastError() != ERROR_INSUFFICIENT_BUFFER) {
104 error(_("[GLE %ld] could not convert path to UTF-8: '%.*ls'"),
86 - GetLastError(),
87 - (int)(info->FileNameLength / sizeof(WCHAR)),
88 - info->FileName);
105 + GetLastError(), (int)wpath_len, wpath);
106 return -1;
107 }
108
@@ -98,6 +115,173 @@ normalize:
115 return strbuf_normalize_path(normalized_path);
116 }
117
118 +/*
119 + * See if the worktree root directory has shortnames enabled.
120 + * This will help us decide if we need to do an expensive shortname
121 + * to longname conversion on every notification event.
122 + *
123 + * We do not want to create a file to test this, so we assume that the
124 + * root directory contains a ".git" file or directory. (Our caller
125 + * only calls us for the worktree root, so this should be fine.)
126 + *
127 + * Remember the spelling of the shortname for ".git" if it exists.
128 + */
129 +static void check_for_shortnames(struct one_watch *watch)
130 +{
131 + wchar_t buf_in[MAX_PATH + 1];
132 + wchar_t buf_out[MAX_PATH + 1];
133 + wchar_t *last;
134 + wchar_t *p;
135 +
136 + /* build L"<wt-root-path>/.git" */
137 + swprintf(buf_in, ARRAY_SIZE(buf_in) - 1, L"%ls.git",
138 + watch->wpath_longname);
139 +
140 + if (!GetShortPathNameW(buf_in, buf_out, ARRAY_SIZE(buf_out)))
141 + return;
142 +
143 + /*
144 + * Get the final filename component of the shortpath.
145 + * We know that the path does not have a final slash.
146 + */
147 + for (last = p = buf_out; *p; p++)
148 + if (*p == L'/' || *p == '\\')
149 + last = p + 1;
150 +
151 + if (!wcscmp(last, L".git"))
152 + return;
153 +
154 + watch->has_shortnames = 1;
155 + wcsncpy(watch->dotgit_shortname, last,
156 + ARRAY_SIZE(watch->dotgit_shortname));
157 +
158 + /*
159 + * The shortname for ".git" is usually of the form "GIT~1", so
160 + * we should be able to avoid shortname to longname mapping on
161 + * every notification event if the source string does not
162 + * contain a "~".
163 + *
164 + * However, the documentation for GetLongPathNameW() says
165 + * that there are filesystems that don't follow that pattern
166 + * and warns against this optimization.
167 + *
168 + * Lets test this.
169 + */
170 + if (wcschr(watch->dotgit_shortname, L'~'))
171 + watch->has_tilde = 1;
172 +}
173 +
174 +enum get_relative_result {
175 + GRR_NO_CONVERSION_NEEDED,
176 + GRR_HAVE_CONVERSION,
177 + GRR_SHUTDOWN,
178 +};
179 +
180 +/*
181 + * Info notification paths are relative to the root of the watch.
182 + * If our CWD is still at the root, then we can use relative paths
183 + * to convert from shortnames to longnames. If our process has a
184 + * different CWD, then we need to construct an absolute path, do
185 + * the conversion, and then return the root-relative portion.
186 + *
187 + * We use the longname form of the root as our basis and assume that
188 + * it already has a trailing slash.
189 + *
190 + * `wpath_len` is in WCHARS not bytes.
191 + */
192 +static enum get_relative_result get_relative_longname(
193 + struct one_watch *watch,
194 + const wchar_t *wpath, DWORD wpath_len,
195 + wchar_t *wpath_longname, size_t bufsize_wpath_longname)
196 +{
197 + wchar_t buf_in[2 * MAX_PATH + 1];
198 + wchar_t buf_out[MAX_PATH + 1];
199 + DWORD root_len;
200 + DWORD out_len;
201 +
202 + /*
203 + * Build L"<wt-root-path>/<event-rel-path>"
204 + * Note that the <event-rel-path> might not be null terminated
205 + * so we avoid swprintf() constructions.
206 + */
207 + root_len = watch->wpath_longname_len;
208 + if (root_len + wpath_len >= ARRAY_SIZE(buf_in)) {
209 + /*
210 + * This should not happen. We cannot append the observed
211 + * relative path onto the end of the worktree root path
212 + * without overflowing the buffer. Just give up.
213 + */
214 + return GRR_SHUTDOWN;
215 + }
216 + wcsncpy(buf_in, watch->wpath_longname, root_len);
217 + wcsncpy(buf_in + root_len, wpath, wpath_len);
218 + buf_in[root_len + wpath_len] = 0;
219 +
220 + /*
221 + * We don't actually know if the source pathname is a
222 + * shortname or a longname. This Windows routine allows
223 + * either to be given as input.
224 + */
225 + out_len = GetLongPathNameW(buf_in, buf_out, ARRAY_SIZE(buf_out));
226 + if (!out_len) {
227 + /*
228 + * The shortname to longname conversion can fail for
229 + * various reasons, for example if the file has been
230 + * deleted. (That is, if we just received a
231 + * delete-file notification event and the file is
232 + * already gone, we can't ask the file system to
233 + * lookup the longname for it. Likewise, for moves
234 + * and renames where we are given the old name.)
235 + *
236 + * Since deleting or moving a file or directory by its
237 + * shortname is rather obscure, I'm going ignore the
238 + * failure and ask the caller to report the original
239 + * relative path. This seems kinder than failing here
240 + * and forcing a resync. Besides, forcing a resync on
241 + * every file/directory delete would effectively
242 + * cripple monitoring.
243 + *
244 + * We might revisit this in the future.
245 + */
246 + return GRR_NO_CONVERSION_NEEDED;
247 + }
248 +
249 + if (!wcscmp(buf_in, buf_out)) {
250 + /*
251 + * The path does not have a shortname alias.
252 + */
253 + return GRR_NO_CONVERSION_NEEDED;
254 + }
255 +
256 + if (wcsncmp(buf_in, buf_out, root_len)) {
257 + /*
258 + * The spelling of the root directory portion of the computed
259 + * longname has changed. This should not happen. Basically,
260 + * it means that we don't know where (without recomputing the
261 + * longname of just the root directory) to split out the
262 + * relative path. Since this should not happen, I'm just
263 + * going to let this fail and force a shutdown (because all
264 + * subsequent events are probably going to see the same
265 + * mismatch).
266 + */
267 + return GRR_SHUTDOWN;
268 + }
269 +
270 + if (out_len - root_len >= bufsize_wpath_longname) {
271 + /*
272 + * This should not happen. We cannot copy the root-relative
273 + * portion of the path into the provided buffer without an
274 + * overrun. Just give up.
275 + */
276 + return GRR_SHUTDOWN;
277 + }
278 +
279 + /* Return the worktree root-relative portion of the longname. */
280 +
281 + wcscpy(wpath_longname, buf_out + root_len);
282 + return GRR_HAVE_CONVERSION;
283 +}
284 +
285 void fsm_listen__stop_async(struct fsmonitor_daemon_state *state)
286 {
287 SetEvent(state->backend_data->hListener[LISTENER_SHUTDOWN]);
@@ -111,7 +295,9 @@ static struct one_watch *create_watch(struct fsmonitor_daemon_state *state,
295 DWORD share_mode =
296 FILE_SHARE_WRITE | FILE_SHARE_READ | FILE_SHARE_DELETE;
297 HANDLE hDir;
114 - wchar_t wpath[MAX_PATH];
298 + DWORD len_longname;
299 + wchar_t wpath[MAX_PATH + 1];
300 + wchar_t wpath_longname[MAX_PATH + 1];
301
302 if (xutftowcs_path(wpath, path) < 0) {
303 error(_("could not convert to wide characters: '%s'"), path);
@@ -128,6 +314,21 @@ static struct one_watch *create_watch(struct fsmonitor_daemon_state *state,
314 return NULL;
315 }
316
317 + len_longname = GetLongPathNameW(wpath, wpath_longname,
318 + ARRAY_SIZE(wpath_longname));
319 + if (!len_longname) {
320 + error(_("[GLE %ld] could not get longname of '%s'"),
321 + GetLastError(), path);
322 + CloseHandle(hDir);
323 + return NULL;
324 + }
325 +
326 + if (wpath_longname[len_longname - 1] != L'/' &&
327 + wpath_longname[len_longname - 1] != L'\\') {
328 + wpath_longname[len_longname++] = L'/';
329 + wpath_longname[len_longname] = 0;
330 + }
331 +
332 CALLOC_ARRAY(watch, 1);
333
334 watch->buf_len = sizeof(watch->buffer); /* assume full MAX_RDCW_BUF */
@@ -135,6 +336,9 @@ static struct one_watch *create_watch(struct fsmonitor_daemon_state *state,
336 strbuf_init(&watch->path, 0);
337 strbuf_addstr(&watch->path, path);
338
339 + wcscpy(watch->wpath_longname, wpath_longname);
340 + watch->wpath_longname_len = len_longname;
341 +
342 watch->hDir = hDir;
343 watch->hEvent = CreateEvent(NULL, TRUE, FALSE, NULL);
344
@@ -258,6 +462,62 @@ static void cancel_rdcw_watch(struct one_watch *watch)
462 watch->is_active = FALSE;
463 }
464
465 +/*
466 + * Process a single relative pathname event.
467 + * Return 1 if we should shutdown.
468 + */
469 +static int process_1_worktree_event(
470 + struct string_list *cookie_list,
471 + struct fsmonitor_batch **batch,
472 + const struct strbuf *path,
473 + enum fsmonitor_path_type t,
474 + DWORD info_action)
475 +{
476 + const char *slash;
477 +
478 + switch (t) {
479 + case IS_INSIDE_DOT_GIT_WITH_COOKIE_PREFIX:
480 + /* special case cookie files within .git */
481 +
482 + /* Use just the filename of the cookie file. */
483 + slash = find_last_dir_sep(path->buf);
484 + string_list_append(cookie_list,
485 + slash ? slash + 1 : path->buf);
486 + break;
487 +
488 + case IS_INSIDE_DOT_GIT:
489 + /* ignore everything inside of "<worktree>/.git/" */
490 + break;
491 +
492 + case IS_DOT_GIT:
493 + /* "<worktree>/.git" was deleted (or renamed away) */
494 + if ((info_action == FILE_ACTION_REMOVED) ||
495 + (info_action == FILE_ACTION_RENAMED_OLD_NAME)) {
496 + trace2_data_string("fsmonitor", NULL,
497 + "fsm-listen/dotgit",
498 + "removed");
499 + return 1;
500 + }
501 + break;
502 +
503 + case IS_WORKDIR_PATH:
504 + /* queue normal pathname */
505 + if (!*batch)
506 + *batch = fsmonitor_batch__new();
507 + fsmonitor_batch__add_path(*batch, path->buf);
508 + break;
509 +
510 + case IS_GITDIR:
511 + case IS_INSIDE_GITDIR:
512 + case IS_INSIDE_GITDIR_WITH_COOKIE_PREFIX:
513 + default:
514 + BUG("unexpected path classification '%d' for '%s'",
515 + t, path->buf);
516 + }
517 +
518 + return 0;
519 +}
520 +
521 /*
522 * Process filesystem events that happen anywhere (recursively) under the
523 * <worktree> root directory. For a normal working directory, this includes
@@ -274,6 +534,7 @@ static int process_worktree_events(struct fsmonitor_daemon_state *state)
534 struct string_list cookie_list = STRING_LIST_INIT_DUP;
535 struct fsmonitor_batch *batch = NULL;
536 const char *p = watch->buffer;
537 + wchar_t wpath_longname[MAX_PATH + 1];
538
539 /*
540 * If the kernel gets more events than will fit in the kernel
@@ -306,54 +567,64 @@ static int process_worktree_events(struct fsmonitor_daemon_state *state)
567 */
568 for (;;) {
569 FILE_NOTIFY_INFORMATION *info = (void *)p;
309 - const char *slash;
570 + wchar_t *wpath = info->FileName;
571 + DWORD wpath_len = info->FileNameLength / sizeof(WCHAR);
572 enum fsmonitor_path_type t;
573 + enum get_relative_result grr;
574 +
575 + if (watch->has_shortnames) {
576 + if (!wcscmp(wpath, watch->dotgit_shortname)) {
577 + /*
578 + * This event exactly matches the
579 + * spelling of the shortname of
580 + * ".git", so we can skip some steps.
581 + *
582 + * (This case is odd because the user
583 + * can "rm -rf GIT~1" and we cannot
584 + * use the filesystem to map it back
585 + * to ".git".)
586 + */
587 + strbuf_reset(&path);
588 + strbuf_addstr(&path, ".git");
589 + t = IS_DOT_GIT;
590 + goto process_it;
591 + }
592
312 - strbuf_reset(&path);
313 - if (normalize_path_in_utf8(info, &path) == -1)
314 - goto skip_this_path;
315 -
316 - t = fsmonitor_classify_path_workdir_relative(path.buf);
317 -
318 - switch (t) {
319 - case IS_INSIDE_DOT_GIT_WITH_COOKIE_PREFIX:
320 - /* special case cookie files within .git */
321 -
322 - /* Use just the filename of the cookie file. */
323 - slash = find_last_dir_sep(path.buf);
324 - string_list_append(&cookie_list,
325 - slash ? slash + 1 : path.buf);
326 - break;
327 -
328 - case IS_INSIDE_DOT_GIT:
329 - /* ignore everything inside of "<worktree>/.git/" */
330 - break;
593 + if (watch->has_tilde && !wcschr(wpath, L'~')) {
594 + /*
595 + * Shortnames on this filesystem have tildes
596 + * and the notification path does not have
597 + * one, so we assume that it is a longname.
598 + */
599 + goto normalize_it;
600 + }
601
332 - case IS_DOT_GIT:
333 - /* "<worktree>/.git" was deleted (or renamed away) */
334 - if ((info->Action == FILE_ACTION_REMOVED) ||
335 - (info->Action == FILE_ACTION_RENAMED_OLD_NAME)) {
336 - trace2_data_string("fsmonitor", NULL,
337 - "fsm-listen/dotgit",
338 - "removed");
602 + grr = get_relative_longname(watch, wpath, wpath_len,
603 + wpath_longname,
604 + ARRAY_SIZE(wpath_longname));
605 + switch (grr) {
606 + case GRR_NO_CONVERSION_NEEDED: /* use info buffer as is */
607 + break;
608 + case GRR_HAVE_CONVERSION:
609 + wpath = wpath_longname;
610 + wpath_len = wcslen(wpath);
611 + break;
612 + default:
613 + case GRR_SHUTDOWN:
614 goto force_shutdown;
615 }
341 - break;
616 + }
617
343 - case IS_WORKDIR_PATH:
344 - /* queue normal pathname */
345 - if (!batch)
346 - batch = fsmonitor_batch__new();
347 - fsmonitor_batch__add_path(batch, path.buf);
348 - break;
618 +normalize_it:
619 + if (normalize_path_in_utf8(wpath, wpath_len, &path) == -1)
620 + goto skip_this_path;
621
350 - case IS_GITDIR:
351 - case IS_INSIDE_GITDIR:
352 - case IS_INSIDE_GITDIR_WITH_COOKIE_PREFIX:
353 - default:
354 - BUG("unexpected path classification '%d' for '%s'",
355 - t, path.buf);
356 - }
622 + t = fsmonitor_classify_path_workdir_relative(path.buf);
623 +
624 +process_it:
625 + if (process_1_worktree_event(&cookie_list, &batch, &path, t,
626 + info->Action))
627 + goto force_shutdown;
628
629 skip_this_path:
630 if (!info->NextEntryOffset)
@@ -382,6 +653,9 @@ force_shutdown:
653 * Note that we DO NOT get filesystem events on the external <gitdir>
654 * itself (it is not inside something that we are watching). In particular,
655 * we do not get an event if the external <gitdir> is deleted.
656 + *
657 + * Also, we do not care about shortnames within the external <gitdir>, since
658 + * we never send these paths to clients.
659 */
660 static int process_gitdir_events(struct fsmonitor_daemon_state *state)
661 {
@@ -403,8 +677,10 @@ static int process_gitdir_events(struct fsmonitor_daemon_state *state)
677 const char *slash;
678 enum fsmonitor_path_type t;
679
406 - strbuf_reset(&path);
407 - if (normalize_path_in_utf8(info, &path) == -1)
680 + if (normalize_path_in_utf8(
681 + info->FileName,
682 + info->FileNameLength / sizeof(WCHAR),
683 + &path) == -1)
684 goto skip_this_path;
685
686 t = fsmonitor_classify_path_gitdir_relative(path.buf);
@@ -538,6 +814,8 @@ int fsm_listen__ctor(struct fsmonitor_daemon_state *state)
814 if (!data->watch_worktree)
815 goto failed;
816
817 + check_for_shortnames(data->watch_worktree);
818 +
819 if (state->nr_paths_watching > 1) {
820 data->watch_gitdir = create_watch(state,
821 state->path_gitdir_watch.buf);
t/t7527-builtin-fsmonitor.sh
+65
@@ -166,6 +166,71 @@ test_expect_success 'implicit daemon stop (rename .git)' '
166 test_must_fail git -C test_implicit_2 fsmonitor--daemon status
167 '
168
169 +# File systems on Windows may or may not have shortnames.
170 +# This is a volume-specific setting on modern systems.
171 +# "C:/" drives are required to have them enabled. Other
172 +# hard drives default to disabled.
173 +#
174 +# This is a crude test to see if shortnames are enabled
175 +# on the volume containing the test directory. It is
176 +# crude, but it does not require elevation like `fsutil`.
177 +#
178 +test_lazy_prereq SHORTNAMES '
179 + mkdir .foo &&
180 + test -d "FOO~1"
181 +'
182 +
183 +# Here we assume that the shortname of ".git" is "GIT~1".
184 +test_expect_success MINGW,SHORTNAMES 'implicit daemon stop (rename GIT~1)' '
185 + test_when_finished "stop_daemon_delete_repo test_implicit_1s" &&
186 +
187 + git init test_implicit_1s &&
188 +
189 + start_daemon -C test_implicit_1s &&
190 +
191 + # renaming the .git directory will implicitly stop the daemon.
192 + # this moves {.git, GIT~1} to {.gitxyz, GITXYZ~1}.
193 + # the rename-from FS Event will contain the shortname.
194 + #
195 + mv test_implicit_1s/GIT~1 test_implicit_1s/.gitxyz &&
196 +
197 + sleep 1 &&
198 + # put it back so that our status will not crawl out to our
199 + # parent directory.
200 + # this moves {.gitxyz, GITXYZ~1} to {.git, GIT~1}.
201 + mv test_implicit_1s/.gitxyz test_implicit_1s/.git &&
202 +
203 + test_must_fail git -C test_implicit_1s fsmonitor--daemon status
204 +'
205 +
206 +# Here we first create a file with LONGNAME of "GIT~1" before
207 +# we create the repo. This will cause the shortname of ".git"
208 +# to be "GIT~2".
209 +test_expect_success MINGW,SHORTNAMES 'implicit daemon stop (rename GIT~2)' '
210 + test_when_finished "stop_daemon_delete_repo test_implicit_1s2" &&
211 +
212 + mkdir test_implicit_1s2 &&
213 + echo HELLO >test_implicit_1s2/GIT~1 &&
214 + git init test_implicit_1s2 &&
215 +
216 + test_path_is_file test_implicit_1s2/GIT~1 &&
217 + test_path_is_dir test_implicit_1s2/GIT~2 &&
218 +
219 + start_daemon -C test_implicit_1s2 &&
220 +
221 + # renaming the .git directory will implicitly stop the daemon.
222 + # the rename-from FS Event will contain the shortname.
223 + #
224 + mv test_implicit_1s2/GIT~2 test_implicit_1s2/.gitxyz &&
225 +
226 + sleep 1 &&
227 + # put it back so that our status will not crawl out to our
228 + # parent directory.
229 + mv test_implicit_1s2/.gitxyz test_implicit_1s2/.git &&
230 +
231 + test_must_fail git -C test_implicit_1s2 fsmonitor--daemon status
232 +'
233 +
234 test_expect_success 'cannot start multiple daemons' '
235 test_when_finished "stop_daemon_delete_repo test_multiple" &&
236