メモ帳で開くとファイルの最終アクセス日が更新されない

Windowsでテキストファイルの中身を見たり編集する際は、メモ帳(notepad.exe)を良く使います。
今回は、そのメモ帳で起きた不思議な出来事と、その原因調査について報告します。最後に、メモ帳の進化について簡単に触れます。

### ファイルを開いても、最終アクセス日が変わらない
[前回](https://qiita.com/diizuka-biz/items/dbefefc92d026d1cc0a3)の記事で、FAT32上のファイルでは、最終アクセス日時ではなく、最終アクセス「日」を記録するとわかりました。

この調査中、FAT32フォーマットされたUSBメモリ上のファイルをWindowsから開く実験をしている際に、またまた不思議な現象に遭遇しました。その時にやった手順と起きた現象は以下の通りです。

- USBメモリ上にある大きなmpegファイルを再生して、最終アクセス日を更新していた
- mpegファイルが大きいため、Explorer上でmpegファイルをダブルクリックしてから、再生開始されるまでに数秒かかる課題があった
- 動画ファイルの代わりに小さなtextファイルを開いて、最終アクセス日を更新する事を考えた
- ところが、Explorer上でtextファイルをダブルクリックしてメモ帳を開いてみたが、textファイルの最終アクセス日が何故か更新されない
- メモ帳の代わりに、テキストファイルをWordで開いたり、cmd.exeのtypeコマンドで内容表示すると最終アクセス日が更新された

一体何故こうなるのでしょうか？ひょっとすると、メモ帳では通常とは違う手段でファイルを開いているのかもしれません。調べてみましょう

# メモ帳がファイルを開く方法を、ProcessMonitorで調べる
Windows上のアプリは、Win32 APIを読んで様々な処理をします。あるアプリがどんなWin32 APIを呼んでいるかは、[ProcessMonitor](https://docs.microsoft.com/en-us/sysinternals/downloads/procmon)を使うとわかります。
ProcessMonitorは、旧Sysinternal社が開発したツールです。同社はMicrosoftに買収され、現在はMicrosoftのサイトから無償提供されています。Linuxでのstraceやltraceコマンドに相当します。

[Microsoftのサイト](https://docs.microsoft.com/en-us/sysinternals/downloads/procmon)からダウンロートしてzipを展開します。32bitではProcmon.exe、64bitではProcmon64.exe を実行します。初回起動時はライセンス条項が表示されます。Admin権限で動作するためUACダイアログが表示されます。「はい」と回答すると起動します。

### メモ帳でファイルを開く時の挙動を調査
標準では、ProcessExporerは全てのWindows Eventを表示するので、そのままではあっという間に大量のログで溢れてしまいます。メモ帳(notepad.exe)に対するファイル操作のみを記録するため、フィルタを設定します。

前回同様、FAT32フォーマットされたUSBメモリ上のファイルを開く事を考えます。ここでは、USBメモリが I:\ だとします。漏斗のアイコンをクリックします。
![procmon_routo.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/695115/6d34df8a-fb3c-d2bd-1998-fb55e2145b86.png)

出てきたダイアログに、以下のフィルタ条件を追加(Add)します。

- Image Path is C:\WINDOWS\System32\notepad.exe
- Path begins with I:\

これで、USBメモリ上のファイルをメモ帳で開くときに関連するイベントのみが表示されます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/695115/66f6a7ed-c50f-3224-65b7-85c30a61888c.png)

### USBメモリのキャッシュを削除しておく
Process Explorer起動前に、すでにUSBメモリからファイルを読んでいると、メモリ上に内容がキャッシュされます。そのままでは、幾つかのWin32 APIが呼ばれない事があります。一回USBメモリを取り外して、再度接続することで、キャッシュを削除しましょう。

### メモ帳でファイルを開く
それでは、虫眼鏡のアイコンをクリックして赤い×印を消し、イベントをキャプチャしましょう。
USBメモリ上のtext fileをExplorer上でダブルクリックして、メモ帳で開きます。
開いたら、虫眼鏡のアイコンをクリックして赤い×印にして、キャプチャを止めます。


キャプチャした結果は以下の通りです。
![68747470733a2f2f71696974612d696d6167652d73746f72652e73332e61702d6e6f727468656173742d312e616d617a6f6e6177732e636f6d2f302f3639353131352f34303935333932312d303766612d326530302d656434372d3136613061613435.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/695115/506be4fe-c0ea-2716-c685-6dc2d8323949.png)


ファイルを読む際は、通常は以下のWin32 APIを使います

- CreateFile: ファイルを開く
- ReadFile: ファイルを読む
- CloseHandle: ファイルを閉じる (ProcessExplorerではCloseFileイベントになる)

キャプチャした結果を見てみると、おや？ ReadFileではなく、CreateFileMappingが呼ばれていますね。

## CreateFileMappingとは
[CreateFileMapping](https://docs.microsoft.com/en-us/windows/win32/api/memoryapi/nf-memoryapi-createfilemappingw)は、ファイルの特定範囲をメモリ上に対応付けます。どの範囲を対応付けるか指定する、[MapViewOfFile](https://docs.microsoft.com/en-us/windows/win32/api/memoryapi/nf-memoryapi-mapviewoffile)と組み合わせて使います。メモリ上の内容を変更すると、ファイルの内容も書き換わります。複数プロセス間でファイルを共有してデータをやり取りする際などに使われます。Linuxではmmap(2)に相当します。

CreateFileMappingでファイルを読むと、最終アクセス日が更新されないのでしょうか？確かめてみましょう。

## CreateFilePaiingを直接呼んで挙動確認
CreateFileMappingを使うときとReadFileを使うときで、最終アクセス時刻がどう変化するのか、実際にコードを書いて試してみましょう。

ファイルの最終アクセス時刻を遅らせてから(backToFewMinutesAgo)、CreateFileMappingとReadFileでアクセスし、最終アクセス時刻がどう変化したか表示します。ファイルの内容が読めているか確認するために、先頭から100文字程度を表示します。

今回はWindows 64bit版のPython 3.8.5を使います。Pythonには、Win32 APIをはじめ、外部ライブラリを呼び出す機能(ctypes)が標準で入っているため、VisualStudioやmingw32等のC/C++の開発環境を別途用意しなくてもWin32 APIを呼び出せます。Pythonのctypesでは引数や戻り値の型はデフォルトで32bitです。64bitのWindowsではポインタが64bitなので、ポインタを使う箇所では64bitの型を明示しておきます。

```python:mapvsread.py
import ctypes as ct
import ctypes.wintypes as wt
import stat
import os
import sys
import datetime

GENERIC_READ = 0x40000000
GENERIC_WRITE = 0x80000000
FILE_SHARE_READ = 1
OPEN_EXISTING = 3
PAGE_READWRITE = 4
FILE_MAP_WRITE = 2
FILE_MAP_READ = 4
kr = ct.windll.KERNEL32

# ヘルパー関数群
def createFile(file):
    h = kr.CreateFileW(file, GENERIC_READ|GENERIC_WRITE, FILE_SHARE_READ, None,
          OPEN_EXISTING, stat.FILE_ATTRIBUTE_NORMAL, None)
    if kr.GetLastError() != 0:
        raise Exception()
    return h

def closeHandle(h):
    ret = kr.CloseHandle(h)
    if ret != 1:
        raise Exception()

def today():
    now = datetime.datetime.utcnow().date()
    today = datetime.datetime(now.year, now.month, now.day)
    return today.timestamp()

def backToFewMinutesAgo(file):
    h = createFile(file)
    lastacess = (wt.DWORD * 2)() # 32bit x 2の配列。単位は100ns
    ret = kr.GetFileTime(h, None, lastacess, None)
    if ret != 1:
        raise Exception()
    lastacess[1] -= 1; # 上位32bitを1減らすと約7分前に。FAT32では前日に切り捨て
    ret = kr.SetFileTime(h, None, lastacess, None)
    if ret != 1:
        raise Exception()
    closeHandle(h)

# CreateFileMappingを使う
def mapFile(file):
    h = createFile(file)
    m = kr.CreateFileMappingW(h, None, PAGE_READWRITE, 0, 0, None)
    err = ct.GetLastError()
    if kr.GetLastError() != 0:
        raise Exception()

    # 戻り値の型はデフォルトで32bit int。pointerは64bitなので型を明示
    kr.MapViewOfFile.restype = ct.c_void_p
    p = kr.MapViewOfFile(m, FILE_MAP_READ|FILE_MAP_WRITE, 0, 0, 0)
    if kr.GetLastError() != 0:
        raise Exception()

    # pythonではポインタpが指す先を直接参照できない。一旦バッファにコピー
    buf = ct.create_string_buffer(100)
    ct.memmove(buf, p, 99)
    print(buf.value.decode())

    # 後始末
    kr.UnmapViewOfFile.argtypes = [ct.c_void_p] # 引数の型を明示
    ret = kr.UnmapViewOfFile(p)
    if ret != 1:
        raise Exception()
    closeHandle(m)
    closeHandle(h)

# ReadFileを使う
def readFile(file):
    h = createFile(file)
    buf = ct.create_string_buffer(100)
    pNumOfBytesHasRead = (wt.DWORD * 1)() # 読んだサイズ
    ret = kr.ReadFile(h, buf, 99, pNumOfBytesHasRead, None)
    if ret != 1:
        raise Exception()
    print(buf.value.decode())
    closeHandle(h)

def main():
    file = sys.argv[1]
    backToFewMinutesAgo(file)

    atime = os.stat(file).st_atime
    print(f"{atime} before")
    mapFile(file)
    atime = os.stat(file).st_atime
    print(f"{atime} after CreateFileMapping")
    readFile(file)
    atime = os.stat(file).st_atime
    print(f"{atime} after ReadFile")
    print(f"{today()} today")

main()
```

実行結果の数値は、epoch(1970/1/1 UTC)からの経過秒です。
最終アクセス日がCreateFileMappingでは変わらないが、ReadFileでは現在日に変わる事がわかります。

```:cmd.exe
C:\src> python mapvsread.py i:\a.txt
1600873200.0 before
contents of a.txt
1600873200.0 after CreateFileMapping
contents of a.txt
1600959600.0 after ReadFile
1600959600.0 today
```


## NTFSでは？
FAT32ではなく、NTFS上のファイルに対してはどうでしょうか？
Windows 10では、[性能向上](https://docs.microsoft.com/ja-jp/windows-server/administration/windows-commands/fsutil-behavior)を目的に、NTFSでは最終アクセス日時を記録しない設定にしてある場合があります。管理者権限で以下のコマンドを実行して、最終アクセス日時の記録有無を確認します。記録しない場合は、無効に設定してOS再起動し反映させます。設定を変更したら、テスト後に元に戻しておきましょう

```:cmd.exe_Admin権限
C:\WINDOWS\system32>fsutil behavior query disablelastaccess
DisableLastAccess = 1  (ユーザー管理、有効)
# 有効のときは、一時的に無効にする。
C:\WINDOWS\system32>fsutil behavior set disablelastaccess 0
DisableLastAccess = 0  (ユーザー管理、無効)
```

実行結果は以下の通りです。NTFSでは、CreateFileMapping、ReadFile、双方ともファイルを読んだ時点で最終アクセス日時が更新されました。

```:cmd.exe
C:\src>python mapvsread.py a.txt
1601013705.132326 before
contents of a.txt
1601019801.3998694 after CreateFileMapping
contents of a.txt
1601019801.4008653 after ReadFile
1600959600.0 today
```

FAT32，かつ、CreateFileMappingの場合にのみ、最終アクセス日が記録されないようです。どうしてこんな仕様になっているのかは不明です。

## 原因はメモ帳の改良の歴史にあった
WordやtypeコマンドはReadFileを使うのに、どうしてメモ帳ではCreateFileMappingを使っているのでしょうか？ヒントになる記事がありました。
https://devblogs.microsoft.com/oldnewthing/20180521-00/?p=98795

メモ帳はWindows 1.0の頃からあり、今でも改良が続けられています。より早くファイルを読めるように、他プロセスがファイルの一部をロックしていても内容を表示できるようにするために、memory mapped file を使うように改良したそうです。

## 商標

- Windowsは，米国Microsoft Corporationの米国およびその他の国における登録商標または商標です
- Linuxは、Linus Torvalds氏の日本およびその他の国における登録商標または商標です。
- 記載の会社名、製品名、サービス名等はそれぞれの会社の商標または登録商標です。