Особливості файлових систем, з якими ми зіткнулися при розробці механізму синхронізації Хмари Mail.Ru



Одна з основних функцій десктопного клієнта Хмари Mail.Ru — синхронізація даних. Її метою є приведення папки на ПК і її уявлення в Хмарі до однакового стану. При розробці цього механізму ми зустрілися з деякими, з першого погляду, досить очевидними особливостями різних файлових і операційних систем. Проте якщо про них не знати, можна зіткнутися з досить неприємними наслідками (не вийде завантажити або видалити файл). У цій статті ми зібрали особливості, знання яких дозволить вам правильно працювати з даними на дисках і, можливо, позбавить від необхідності термінового хотфикса.

1. Події від файлової системи не гарантують повну картину події
Будь механізм синхронізації директорій вимагає моніторинг змін стану файлів і папок. Благо API кожної операційної системи надає нам таку можливість. Ми використовуємо ReadDirectoryChangesW для Windows, FSEventStream для macOS і inotify для Linux. І вже тут підстерігають неприємні моменти. Справа в тому, що під macOS не можна з упевненістю сказати, яка саме подія прийшло від файлової системи. Ви запросто можете отримати CREATED, DELETED, RENAMED, MODIFIED на файл в одній події. І начебто все логічно: якщо є видалення, значить файлу вже немає, однак:

$ rm 1.txt && echo "привіт" > 1.txt

прийде однією подією:

1.txt: CREATED | REMOVED | MODIFIED

Тому доводиться використовувати додаткові перевірки подій для розуміння, що саме сталося з файлом або директорією.

У inotify чергу подій може переповнитися, і можна почати втрачати їх до того моменту, поки ви не заберете деякі евенти з черги. При цьому втрачені події вам ніяк не компенсуються, і потрібно буде виконувати дорогі операції зразок обходу по диску.

2. З символічними посиланнями не вийде працювати як із звичайними файлами
Символічні посилання можуть бути зацикленими: A -> B> C -> B. Вирішити цю проблему можна, наприклад, за допомогою номери inode (унікальний номер файлу або папки в поточному розділі диска, але про них трохи нижче). У нашому випадку ми зберігаємо список inode символічних посилань, за якими пройшли до поточної директорії. Якщо inode поточної символічного посилання збігається з тим, що вже є в списку, то вважаємо її замикатися і пропускаємо.

Символічна посилання може виявитися битою. Якщо в якийсь момент контент, на який вказувала символічна посилання буде переміщено або видалено, то посилання стане недоступною. Важливо правильно обробити цей момент.

Якщо ви підписані на подію від директорії, в якій у вас є символічні посилання на інші директорії, то події про зміну вмісту за символічною посилання приходити не будуть.

3. Імена файлів і папок можуть бути неправильної UTF-16
Був один цікавий баг. В локальному дереві користувача, який зарепортил нам проблему, був файл. Однак при спробі його читання ми розуміли, що файлу немає. Начебто логічна ситуація, коли в момент нашої роботи файл видаляється. Але при наступному лістингу директорії файл знову був на місці. Справа в тому, що під Windows можна створити невалидную кодування UTF-16. Точніше, назва може містити невалидную сурогатну пару. Конвертувати таку назву в UTF-8, а потім назад в UTF-16 стандартними засобами (WideCharToMultiByte, MultiByteToWideChar) не вийде. Візьмемо приклад:

wchar_t name[] = { 0xDCA9, 0x2E, 0x74, 0x78, 0x74, 0x00 };



Сурогатні пари складаються з High і Low значення і потрібні для того, щоб розширити діапазон кодованих символів. High Surrogates лежать в діапазоні xD800 — xDB7F. Low Surrogates в діапазоні DC00 — DFFF. У нашому назві ми взяли High, але не взяли Low. Таким чином, ми отримали невалидный UTF-16.

Конвертуємо таку назву в UTF-8, потім назад:

wchar_t name2[] = { 0xFFFD, 0x2E, 0x74, 0x78, 0x74, 0x00 }; // "�.txt"

Символ, який представляє початок сурогатної пари, ламається. Звернутися за такою назвою вже не вийде.

Код прикладу
#include <assert.h>
#include < string>
#include <Windows.h>

std::string utf16ToUtf8(const std::wstring& utf16) {
int size = WideCharToMultiByte(CP_UTF8, 0, utf16.data(), static_cast<int>(utf16.size()), NULL, 0, NULL, NULL);
std::string utf8(size, 0x00);
WideCharToMultiByte(CP_UTF8, 0, utf16.data(), static_cast<int>(utf16.size()), &utf8[0], size, NULL, NULL);
return utf8;
}

std::wstring utf8ToUtf16(const std::string& utf8) {
int size = MultiByteToWideChar(CP_UTF8, 0, utf8.data(), static_cast<int>(utf8.size()), NULL, 0);
std::wstring utf16(size, 0x00);
MultiByteToWideChar(CP_UTF8, 0, utf8.data(), static_cast<int>(utf8.size()), &utf16[0], size);
return utf16;
}

int main() {
std::wstring original_utf16 = { 0xDCA9, 0x2E, 0x74, 0x78, 0x74, 0x00 };

// Створюємо файл з невалидной сурогатної парою
HANDLE handle = CreateFileW(original_utf16.c_str(), GENERIC_WRITE, 0, NULL, CREATE_NEW, FILE_ATTRIBUTE_NORMAL, NULL);
if (handle == INVALID_HANDLE_VALUE) {
return 1;
}

CloseHandle(handle);

// Перетворимо назва в UTF-8 і назад
std::string utf8 = utf16ToUtf8(original_utf16);
std::wstring utf16 = utf8ToUtf16(utf8);

// Знову намагаємося відкрити файл з перетвореним назвою
handle = CreateFileW(utf16.c_str(), GENERIC_READ, 0, NULL, OPEN_EXISTING, 0, NULL);
if (handle == INVALID_HANDLE_VALUE) {
// Не маємо доступу до файлу
assert(original_utf16 == utf16);
return 1;
}

// Сюди ніколи не прийдемо
CloseHandle(handle);
return 0;
}

Ми в модулі синхронізації завжди працюємо з UTF-8. Отримуємо від файлової системи події або лістинг і перетворимо назви у UTF-8. Сервер також працює з UTF-8. При зверненні до файлової системи, ми перетворимо UTF-8 назад в UTF-16. Проблема була вирішена забороною синхронізації валідних UTF-16.

4. Підводні камені при роботі з inodes
Довгий час десктопний клієнт не підтримував перейменування файлів і папок. Замість цього подія перейменування оброблялося видаленням файлів в одному місці і створенням в іншому. Цей механізм працював досить довго і стабільно. Справа в тому, що видалення файлу з хмари — це лише видалення посилання на цей файл з дерева. Сам файл при цьому залишається ще на якийсь час жити на сервері, щоб ви потім, наприклад, могли його відновити з корзини. Таким чином, видалення і створення файлу в іншому місці обходилися лише відправкою мета-інформації на сервер, який просто видаляв посилання на файл з одного місця і створював її в іншому, навіть не відкриваючи при цьому локальну копію. Однак з появою загальних папок ми стали розуміти, що повинні обробляти саме переміщення (щоб не втратити ознака загальної папки і не відключити приєднану папку).

Одна справа, коли від файлової системи приходить подія перейменування. Тут проблем ніяких. Тиць-тиць і перейменували. А якщо додаток вимкнено? Потрібна якась інформація, з якої ми будемо детектувати подія перейменування. Було кілька варіантів детектування переміщення:

  • Порівнювати ієрархію файлів і папок. Вельми важкий процес, навіть виходячи з того, що дерева зберігаються в оперативній пам'яті.
  • Створювати приховані файли зі службовою інформацією в кожній папці, за яким ми будемо розуміти, куди папка перемістилася у що перейменувалася. Проте це викликає деякі складнощі, включаючи те, що користувач може змінювати і редагувати ці службові файли, що може призвести до неприємних наслідків. Та й «слідкувати» в кожній директорії не хотілося.
  • Inodes. На цьому варіанті ми і зупинилися.
Inode — індексний дескриптор. Позначається цілим числом і являє собою ідентифікатор файлу або папки в конкретній файловій системі.



Трохи більш людяне опис «як це працює» рекомендую почитати статті. У POSIX отримуємо inode з stat (st_ino), у Windows — GetFileInformation (nFileIndex). І все начебто просто:

  1. Клієнт запускається, подгружаем закэшированное подання файлової ієрархії.
  2. Порівнюємо з тим, що зараз лежить на диску за фактом.
  3. Знаходимо вузли, номери inode яких відсутні в місці, де ми вважаємо, але є в якомусь іншому місці.
  4. Переміщуємо ці вузли.
Однак з inodes потрібно бути дуже і дуже обережними. Ось деякі підводні камені, з якими ми зіткнулися.

4.1. Хардлинки
Кожна посилання даного типу на один файл має однаковий номер inode. Ми не детектим перейменування, якщо в дереві є хардлинки. Хардлинк не можна створити папку (ну або майже не можна), тому особливих проблем тут немає.

4.2. Inodes можуть працювати інакше, ніж ви очікуєте від
На деяких файлових системах номери inode присвоюються не так, як повинні (ну або як нам здається, що маємо). Ми вважаємо, що їх номери перейменування файлу не змінюються. Також ми припускаємо, що якщо останній файл на ФС з inode 9 видалити, то такий файл буде мати inode номер 10. На жаль, деякі файлові системи з цим не згодні.

Під macOS FAT створюються нові файли (не папки) з inode номер 9999… перейменування файлів номер inode не змінюється. При редагуванні файлів номери змінюються на порядкові значення, які ми і очікуємо побачити:

$ touch 1.txt
$ ls -i
999999999 1.txt
$ echo "привіт" > 1.txt
$ ls -i
223 1.txt

Ext4. Справа в тому, що якщо на цій файловій системі (яка є стандартною в більшості дистрибутивів Linux), видалити файл з inode номер 9 в одному місці і створити новий файл в іншому місці, він буде мати inode з номером не 10 або вище, а 9.

$ touch 1.txt
$ ls -i
270 1.txt
$ rm 1.txt && touch 2.txt
$ ls -i
270 2.txt

Тобто на цій файловій системі номером inode стає перший вільний номер. Це трохи зламало нам логіку. Рішення прийшло само собою: якщо задетектили перейменування папки, порівнюємо для її вмісту номери inode для папок і хеш + розмір файлів. Якщо директорії збігаються на 70% і вище — перейменовуємо. Для файлів — якщо хеш + розмір збіглися.

З урахуванням того, що нумерація inodes в різних файлових системах працює по-різному, у нас є перевірка, чи працюють inode так, як ми очікуємо: при запуску модуля синхронізації відтворюється тестове поведінка для перевірки. Якщо воно таке, як ми очікуємо, значить з номерами inode можна працювати. Інакше — продовжуємо без підтримки перейменування.

5. Програми зберігають багато службових файлів на диску
Операційні системи і різної популярності програми використовують службові файли на диску, синхронізувати які немає особливого сенсу. Нижче наведено список файлів і масок, які, як ми вважаємо, потрібно ігнорувати:

Windows:
  • desktop.ini — зберігає настройки для поточної директорії;
  • Thumbs.db — кеші ескізів зображень;
  • файли, що починаються з "~$", або ".~", або починаються з "~" і закінчуються ".tmp" — досить поширений шаблон тимчасових файлів. Файли такого шаблону також створює Microsoft Office при редагуванні документів.
macOS:
  • .DS_Store — аналог desktop.ini під Windows;
  • Icon\r — досить цікавий файл, при лістингу файл відображається як «Icon?», зберігає інформацію про зображенні на директорії, в якій знаходиться;
  • файли, що починаються з "._" — досить багато було шаблонів замість цього, однак, різноманітним ЗА більше подобається використовувати свій формат тимчасових файлів, після чого і було вирішено ігнорувати файли по цій масці.
Linux:
  • .directory — аналог desktop.ini під Windows .DS_Store під macOS, що актуально для деяких віконних менеджерів.
6. Особливості шляхів у Windows до файлів і папок

Шляхи під Windows, безумовно, заслуговують окремої уваги. Для шляхів, які перевищують значення MAX_PATH (260 символів), потрібно використовувати перефикс "\\?\". Даний префікс, до речі, потрібно використовувати для CreateFile, якщо ви збираєтеся відкрити COM-порт.

Windows для кожного файлу або теки, назва яких більше 8 символів, створює короткі альясы (ще називаються «8.3»). Альясы завжди у високому регістрі, містять знак "~", за яким йде цифра, що збільшується, якщо такий альяс вже зайнятий (Наприклад: «C:\PROGRA~1\»). Зміст цих ознак необхідно, але не достатньо, щоб зрозуміти — перед вами звичайна назва або короткий альяс. WinApi вміє перетворювати короткі шляху назад в довгі (GetFullPathName). Однак потрібно пам'ятати, що він не перетворить шлях в довге подання, якщо такий файл вже не існує.

Якщо хтось відкриє файл з допомогою CreateFile, використовуючи короткий шлях і модифікує його, то в подію від файлової системи (з допомогою ReadDirectoryChangesW) вам прийде такий самий короткий шлях. У зв'язку з цим ми намагаємося перетворити їх у довгі як можна швидше. До речі, ви можете побачити альясы, якщо введете «dir /x» з потрібної директорії в командному рядку Windows.


Ще однією неприємною особливістю, яку не можна пропустити: файли і папки з крапкою в кінці можна відкрити за допомогою провідника (справедливо для Windows 7):


7. Висновок
Для кожної файлової системи алгоритм синхронізації довелося адаптувати відповідним чином. Кращим рішенням у нашому випадку було відтворення тестового середовища при старті для перевірки тієї папки, яку обирає користувач. І якщо тести не проходять, то ми дізнаємося нову особливість, а користувачеві або забороняємо працювати з цією папкою, або відключаємо якийсь функціонал. Сподіваюся, особливості, з якими ми зіткнулися, допоможуть вам уникнути труднощів при роботі з файловою системою.

Якщо у вас є питання або зауваження, сміливо задавайте їх у коментарях або пишіть особисто мені на a.skogorev@corp.mail.ru.
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.