ohmu
·
2026-10-15
1#include "folders.h"
2#include "common/debug.h"
3#include "common/mallocHelper.h"
4#include "common/windows/nt.h"
5#include "common/windows/registry.h"
6#include "common/windows/unicode.h"
7
8#include <ntstatus.h>
9
10// Where the shell keeps the values SHGetKnownFolderPath() would compute.
11//
12// `User Shell Folders` is the store folder redirection writes to -- HKCU for the user's own
13// folders, HKLM for the machine-wide ones -- and it holds the *unexpanded* form, which is why the
14// environment expansion below is not optional. `ProfileList\<SID>\ProfileImagePath` is what the
15// logon session recorded as the profile directory, and is read by ffGetProfilePath().
16#define FF_FOLDERS_HKCU_SHELL_FOLDERS L"Software\\Microsoft\\Windows\\CurrentVersion\\Explorer\\User Shell Folders"
17#define FF_FOLDERS_HKLM_SHELL_FOLDERS L"SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Explorer\\User Shell Folders"
18#define FF_FOLDERS_HKLM_CURRENT_VERSION L"SOFTWARE\\Microsoft\\Windows\\CurrentVersion"
19#define FF_FOLDERS_HKLM_PROFILE_LIST L"SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion\\ProfileList"
20
21// The environment fallbacks. Two of the registry values above are self-referential -- `Common
22// AppData` is literally `%ProgramData%` -- so these are the same answer, not a different one.
23#define FF_FOLDERS_ENV_LOCAL_APP_DATA L"%LOCALAPPDATA%"
24#define FF_FOLDERS_ENV_ROAMING_APP_DATA L"%APPDATA%"
25#define FF_FOLDERS_ENV_PROGRAM_DATA L"%ProgramData%"
26#define FF_FOLDERS_ENV_PROGRAM_FILES L"%ProgramFiles%"
27
28// Every folder is resolved once and then kept. ffGetKnownFolderPath() is called about a dozen times
29// per process -- getCacheDir, getConfigDirs and getDataDirs all ask for the same folders -- and
30// each answer is a pure function of the registry and this process' environment. `states` separates
31// "not looked up yet" from "looked up and not there", which the path itself cannot: an empty
32// FFstrbuf is a valid answer.
33//
34// No lock guards this: the only thread fastfetch ever creates is the networking module's
35// asynchronous connect, and no module that resolves a known folder runs on it.
36typedef enum FFFolderState : uint8_t {
37 FF_FOLDER_UNRESOLVED,
38 FF_FOLDER_FOUND,
39 FF_FOLDER_MISSING,
40} FFFolderState;
41
42static FFstrbuf folderPaths[FF_KNOWN_FOLDER_COUNT];
43static FFFolderState folderStates[FF_KNOWN_FOLDER_COUNT];
44
45// Expands `%NAME%` placeholders from this process' environment block.
46//
47// The work is done in UTF-16 so that a user name outside the ANSI code page survives: getenv()
48// goes through the CRT's narrow copy of the environment and would mangle it.
49//
50// RtlExpandEnvironmentStrings() substitutes nothing and still reports success for a variable it
51// cannot find (measured), so a leftover '%' is treated as a failure. A directory name that
52// legitimately contains one is a false negative, and the only thing that costs is the caller's
53// fallback.
54static bool expandEnv(const wchar_t* source, uint32_t sourceLen, FFstrbuf* result) {
55 // Asking for the required length is done with a null destination, which the call reports as
56 // STATUS_BUFFER_TOO_SMALL -- a *failure* status that still fills in `size`. The status is
57 // therefore deliberately not checked here; a call that writes nothing leaves `size` at zero.
58 SIZE_T size = 0; // in characters, including the null terminator
59 (void) RtlExpandEnvironmentStrings(nullptr, source, sourceLen, nullptr, 0, &size);
60 if (size == 0) {
61 return false;
62 }
63
64 FF_AUTO_FREE wchar_t* expanded = (wchar_t*) malloc(size * sizeof(wchar_t));
65 SIZE_T written = 0;
66 if (!NT_SUCCESS(RtlExpandEnvironmentStrings(nullptr, source, sourceLen, expanded, size, &written)) || written == 0) {
67 return false;
68 }
69
70 if (wcschr(expanded, L'%')) {
71 return false;
72 }
73
74 ffStrbufSetNWS(result, (uint32_t) (written - 1), expanded);
75 return true;
76}
77
78static bool expandEnvZ(const wchar_t* source, FFstrbuf* result) {
79 return expandEnv(source, (uint32_t) wcslen(source), result);
80}
81
82// `raw` is the payload of a REG_SZ / REG_EXPAND_SZ value: UTF-16, usually null terminated.
83static bool expandBuffer(const FFArgBuffer* raw, FFstrbuf* result) {
84 uint32_t length = raw->length / sizeof(wchar_t);
85 if (length == 0) {
86 return false;
87 }
88
89 const wchar_t* valueW = (const wchar_t*) raw->data;
90 if (valueW[length - 1] == L'\0') {
91 --length;
92 }
93
94 return length > 0 && expandEnv(valueW, length, result);
95}
96
97// FF_ARG_TYPE_BUFFER rather than FF_ARG_TYPE_STRBUF, because the value has to stay UTF-16 for
98// expandEnv(); the strbuf flavour converts it to UTF-8 on the way out. The buffer flavour does not
99// report the registry type either, so REG_SZ and REG_EXPAND_SZ are not told apart -- expanding a
100// string without placeholders is a no-op, which is what makes that acceptable.
101static bool readExpandedValue(HANDLE hKey, const wchar_t* valueNameW, FFstrbuf* result) {
102 FFArgBuffer raw = {};
103 if (!ffRegReadData(hKey, valueNameW, &raw, nullptr)) {
104 return false;
105 }
106 FF_AUTO_FREE void* data = raw.data;
107
108 return expandBuffer(&raw, result);
109}
110
111static bool readExpandedValueFrom(HKEY hRootKey, const wchar_t* subKeyW, const wchar_t* valueNameW, FFstrbuf* result) {
112 FF_AUTO_CLOSE_FD HANDLE hKey = nullptr;
113 if (!ffRegOpenKeyForRead(hRootKey, subKeyW, &hKey, nullptr)) {
114 return false;
115 }
116
117 return readExpandedValue(hKey, valueNameW, result);
118}
119
120// The SID comes from the caller rather than from a token query here: FFPlatform_windows.c already
121// resolves it for `platform->sid`, and the two are the same SID by definition.
122bool ffGetProfilePath(const wchar_t* sidW, FFstrbuf* result) {
123 assert(sidW);
124
125 FF_AUTO_CLOSE_FD HANDLE hProfileList = nullptr;
126 if (!ffRegOpenKeyForRead(HKEY_LOCAL_MACHINE, FF_FOLDERS_HKLM_PROFILE_LIST, &hProfileList, nullptr)) {
127 FF_DEBUG("Cannot open %ls", FF_FOLDERS_HKLM_PROFILE_LIST);
128 return false;
129 }
130
131 FF_AUTO_CLOSE_FD HANDLE hProfile = nullptr;
132 if (!ffRegOpenSubkeyForRead(hProfileList, sidW, &hProfile, nullptr)) {
133 FF_DEBUG("No profile is registered for %ls", sidW);
134 return false;
135 }
136
137 return readExpandedValue(hProfile, L"ProfileImagePath", result);
138}
139
140// `Local AppData` and `AppData` share a key, so they are read together.
141//
142// The names have to be exact, spaces and all: `Local AppData` is `Local`, one space, `AppData` --
143// there is no `Local App Data`, and asking for it fails the whole batch below.
144static const struct {
145 FFKnownFolder folder;
146 const wchar_t* valueNameW;
147 const wchar_t* envW;
148} shellFolderEntries[] = {
149 { FF_KNOWN_FOLDER_LOCAL_APP_DATA, L"Local AppData", FF_FOLDERS_ENV_LOCAL_APP_DATA },
150 { FF_KNOWN_FOLDER_ROAMING_APP_DATA, L"AppData", FF_FOLDERS_ENV_ROAMING_APP_DATA },
151};
152
153// One open plus one NtQueryMultipleValueKey resolves both -- and whichever of the two is asked for
154// first caches the pair, which is what the callers want anyway.
155//
156// NtQueryMultipleValueKey is all-or-nothing: one absent name fails the batch, which would push
157// *both* folders to the environment fallback. That is survivable, because these values are
158// themselves just `%LOCALAPPDATA%` and `%APPDATA%` expanded -- but it is also invisible, and an
159// invisible fallback is how a wrong value name went unnoticed here. So a failed batch is retried
160// one value at a time, and the extra queries are only paid when the batch has already failed.
161static void resolveUserShellFolders(void) {
162 FFArgBuffer buffers[ARRAY_SIZE(shellFolderEntries)] = {};
163 FFRegValueArg args[ARRAY_SIZE(shellFolderEntries)];
164 for (uint32_t i = 0; i < ARRAY_SIZE(shellFolderEntries); ++i) {
165 args[i] = (FFRegValueArg) { .type = FF_ARG_TYPE_BUFFER, .value = &buffers[i], .name = shellFolderEntries[i].valueNameW };
166 }
167
168 FF_AUTO_CLOSE_FD HANDLE hKey = nullptr;
169 if (ffRegOpenKeyForRead(HKEY_CURRENT_USER, FF_FOLDERS_HKCU_SHELL_FOLDERS, &hKey, nullptr)) {
170 if (ffRegReadValues(hKey, ARRAY_SIZE(shellFolderEntries), args, nullptr)) {
171 for (uint32_t i = 0; i < ARRAY_SIZE(shellFolderEntries); ++i) {
172 if (expandBuffer(&buffers[i], &folderPaths[shellFolderEntries[i].folder])) {
173 folderStates[shellFolderEntries[i].folder] = FF_FOLDER_FOUND;
174 }
175 }
176 } else {
177 FF_DEBUG("Batched read of %ls failed; retrying one value at a time", FF_FOLDERS_HKCU_SHELL_FOLDERS);
178 for (uint32_t i = 0; i < ARRAY_SIZE(shellFolderEntries); ++i) {
179 if (readExpandedValue(hKey, shellFolderEntries[i].valueNameW, &folderPaths[shellFolderEntries[i].folder])) {
180 folderStates[shellFolderEntries[i].folder] = FF_FOLDER_FOUND;
181 }
182 }
183 }
184 }
185
186 for (uint32_t i = 0; i < ARRAY_SIZE(shellFolderEntries); ++i) {
187 // Every buffer has to be freed even when the read failed part way through, which is why the
188 // free is not tied to the success path above.
189 free(buffers[i].data);
190
191 FFKnownFolder folder = shellFolderEntries[i].folder;
192 if (folderStates[folder] == FF_FOLDER_UNRESOLVED &&
193 expandEnvZ(shellFolderEntries[i].envW, &folderPaths[folder])) {
194 folderStates[folder] = FF_FOLDER_FOUND;
195 }
196 if (folderStates[folder] == FF_FOLDER_UNRESOLVED) {
197 folderStates[folder] = FF_FOLDER_MISSING;
198 }
199 }
200}
201
202static bool resolveFolder(FFKnownFolder folder) {
203 switch (folder) {
204 case FF_KNOWN_FOLDER_LOCAL_APP_DATA:
205 case FF_KNOWN_FOLDER_ROAMING_APP_DATA:
206 resolveUserShellFolders(); // Resolves both of them
207 return folderStates[folder] == FF_FOLDER_FOUND;
208
209 case FF_KNOWN_FOLDER_PROGRAM_DATA:
210 return readExpandedValueFrom(HKEY_LOCAL_MACHINE, FF_FOLDERS_HKLM_SHELL_FOLDERS, L"Common AppData", &folderPaths[folder]) ||
211 expandEnvZ(FF_FOLDERS_ENV_PROGRAM_DATA, &folderPaths[folder]);
212
213 case FF_KNOWN_FOLDER_PROGRAM_FILES:
214 return readExpandedValueFrom(HKEY_LOCAL_MACHINE, FF_FOLDERS_HKLM_CURRENT_VERSION, L"ProgramFilesDir", &folderPaths[folder]) ||
215 expandEnvZ(FF_FOLDERS_ENV_PROGRAM_FILES, &folderPaths[folder]);
216
217 default:
218 assert(false);
219 return false;
220 }
221}
222
223bool ffGetKnownFolderPath(FFKnownFolder folder, FFstrbuf* result) {
224 assert(result);
225 assert(folder < FF_KNOWN_FOLDER_COUNT);
226
227 if (folderStates[folder] == FF_FOLDER_UNRESOLVED) {
228 folderStates[folder] = resolveFolder(folder) ? FF_FOLDER_FOUND : FF_FOLDER_MISSING;
229 }
230
231 if (folderStates[folder] != FF_FOLDER_FOUND) {
232 return false;
233 }
234
235 ffStrbufSet(result, &folderPaths[folder]);
236 return true;
237}