[Windows] NTFSハードリンクで増分スナップショットをとるバックアップツールを作りました!

スポンサーサイト

上記の広告は1ヶ月以上更新のないブログに表示されています。
新しい記事を書く事で広告が消せます。

[Windows] NTFSハードリンクで増分スナップショットをとるバックアップツールを作りました!

タイトルの通り、NTFSハードリンクを使って増分スナップショットを作成するバックアップツールを作ったので公開します。

LiosK's Rosids-Backup-Tool at master - GitHub

Rosids is a snapshot-style backup tool written in Python 3, emulates the things like pdumpfs, `rsync --link-dest' or Apple's Time Machine on Windows using NTFS hard links.

Rosidsは指定したフォルダのスナップショットを作成するだけの単純なバックアップツールですが、NTFSのハードリンク機能を使うことでバックアップに必要なディスク容量や所要時間を節約する機能を持っています。pdumpfsやrsyncの--link-destオプション (参考)、アップルのTime Machineのような機能だと言えば、わかる人にはわかるでしょうか。

Rosidsは、上に挙げたようなツール群と違って、はじめからWindowsをターゲットに作られているので、ジャンクションやファイル属性のようなWindows特有の機能に対するサポートが手厚いです。加えて、日本語環境でテストされているので、Unicodeファイル名に対する配慮もされています。

細かい特徴や詳しい使い方については続きで。

特徴

  • 指定したフォルダのスナップショットバックアップを作成します。 (⇒ 簡単にバックアップを復元・閲覧できます。)
  • 過去のスナップショットと比較して変更されたファイルだけをコピーし、変更されていないファイルは過去のスナップショットへのハードリンクを作成します。 (⇒ 必要な時間とディスク容量が節約されます。)
  • Unicodeファイル名に対応しています。 (⇒ 日本語、だめ文字、ShiftJIS外の文字等を含むファイルでも問題なくバックアップできます。)
  • ディレクトリジャンクションやシンボリックリンクを除外することができます。 (⇒ Vistaのユーザーディレクトリをバックアップできます。)
  • 指定したファイル属性を持つファイルを除外することができます。 (⇒ 隠しファイル等を除外することができます。)
  • コピーしたファイルに指定した属性を付加することができます。 (⇒ スナップショットにアーカイブ属性、読み取り専用属性等を追加できます。)
  • ディレクトリの属性をコピーできます。 (⇒ 隠しディレクトリはスナップショットでも隠しディレクトリです。)
  • エラーがあった場合はスキップして次のファイルを探索します。 (⇒ アクセス権エラーで停止したりしません。)

インストール

release-1単独のファイルに書かれたPython 3スクリプトです。標準ライブラリだけで書かれているので、Python 3.1がインストールされたWindows環境ではすぐに使うことができます。

使い方

Rosidsは(今のところ)単純なコマンドラインアプリケーションです。オプションを受け取って起動し、標準出力にログを、標準エラー出力にエラーメッセージを出力します。

使い方やオプションのリストは、↓のように--helpオプションを加えてrosids.pyを起動すれば表示されます。

rosids.py --help           (.pyをPython 3に関連付けしている場合)
python rosids.py --help    (Python 3にパスを通した場合)

以下、--helpオプションで出力されるメッセージとそれに対する注釈。

Usage: rosids.py [OPTIONS] SOURCE LINK_SOURCE DESTINATION

create a snapshot-style backup with NTFS hardlinks
  • SOURCE はバックアップ元のディレクトリ
  • LINK_SOURCE は過去のスナップショット (DESTINATIONと同じボリューム上になければいけない)
  • DESTINATION はバックアップ先のパス (空のディレクトリでなければいけない)
Options:
  --version             show program's version number and exit
  -h, --help            show this help message and exit

  Copy Options:
    -l, --list-only     list only - don't copy or link anything
    --afa=RASHNT, --add-file-attr=RASHNT
                        add the given attributes to copied files
  • R = 読み取り専用, A = アーカイブ, S = システム, H = 隠しファイル, N = Not content indexed, T = Temporary
  • コピーされたファイルだけに属性が追加される。ディレクトリやリンクされたファイルには追加されない。
  Selection Options:
    --xfa=RASHCNETO, --exclude-file-by-attr=RASHCNETO
                        exclude files with any of the given attributes
  • C = 圧縮, E = 暗号化, O = オフライン
    --xr=PATTERN, --exclude-by-regexp=PATTERN
                        exclude items matching regular expression PATTERN
  • バックアップ対象ファイルの絶対パスに対して正規表現マッチングを行う。
  • 複数の--xrオプションを指定することができる。
  • デフォルトでは大文字小文字を区別しないマッチングを行うが、PATTERNが(?#casesensitive)で始まる場合は大文字小文字を区別する。
  • ピリオド(\.)やディレクトリセパレータ(\\)の扱いに注意が必要。 (例: --xr "My Profile\\Desktop\\desktop\.ini$")
    --xj, --exclude-junctions
                        exclude junctions (reparse points) - same as --xjd
                        --xjf
    --xjd, --exclude-dir-junctions
                        exclude junctions (reparse points) for directories
    --xjf, --exclude-file-junctions
                        exclude junctions (reparse points) for files
  • --xjd はジャンクション・ディレクトリへのシンボリックリンクを除外する。
  • --xjf はファイルへのシンボリックリンクを除外する。
  Logging Options:
    --verbose           enable verbose log
    --utf8-log          print log messages in UTF-8 encoding
    --utf8-error        print error messages in UTF-8 encoding
  • 標準出力や標準エラー出力をファイルにリダイレクトする場合、--utf8-*を指定してShift_JIS外の文字が失われるのを防いだほうがよい。

使用例

僕がユーザープロファイルフォルダをバックアップするために使っているオプションを例示しておきます。

rosids.py ^
    --verbose ^
    --afa R --xjd --utf8-log ^
    --xr "desktop\.ini$" ^
    --xr "ntuser\.[^\\]*$" ^
    --xr "%USERPROFILE:\=\\%\\Music\\iTunes" ^
    --xr "%USERPROFILE:\=\\%\\AppData\\Local" ^
    --xr "%USERPROFILE:\=\\%\\AppData\\LocalLow" ^
    --xr "%USERPROFILE:\=\\%\\AppData\\Roaming\\Microsoft\\Windows\\Recent" ^
    "%USERPROFILE%" "latest_snapshot" "new_snapshot" ^
    > "rosids.log"
  • "ntuser\.[^\\]*$" は一般的なワイルドカード表記で言うところの ntuser.* に相当します。
  • %USERPROFILE:\=\\%C:\Users\LiosKC:\\Users\\LiosK を変換して正規表現対応にするため。

制約

  • コマンドラインに慣れていない人には敷居が高いです。
  • ネットワークドライブには対応していません。
  • 処理開始前にLINK_SOURCEとDESTINATIONが同じNTFSボリューム上にあるかどうかを確認しないので、違うボリュームのパスを指定した場合、処理を開始してから大量のエラーメッセージが表示されます。

その他

ソースはGitHubからダウンロードすることができますが、念のために貼り付けておきます。

GitHub Project Page
LiosK's Rosids-Backup-Tool at master - GitHub
release-1
LiosK's Rosids-Backup-Tool at release-1 - GitHub:

rosids.py

#!python
"""
Rosids - Create a Snapshot-style Backup with NTFS Hardlinks.

Status:     release-1 (2009-12-11)
Author:     LiosK <contact@mail.liosk.net>
License:    The MIT License
Copyright:  Copyright (c) 2009 LiosK.

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
"""

import ctypes
import io
import optparse
import os
import os.path
import re
import sys


def main(args):
    """Application entry point."""
    parser = create_option_parser()
    (options, arguments) = parser.parse_args(args)

    # checking arguments
    if len(arguments) != 3:
        parser.error("incorrect number of arguments")
    (src, lnk, dst) = map(os.path.abspath, arguments)

    if not os.path.isdir(src):
        parser.error("SOURCE is not an existing directory")
    if not os.path.isdir(lnk):
        parser.error("LINK_SOURCE is not an existing directory")
    if os.path.isdir(dst) and len(os.listdir(dst)) > 0:
        parser.error("DESTINATION is not an empty directory")

    # executing the main process
    walker = create_walker(src, lnk, dst, options)
    try:
        walker.start_walk(src, lnk, dst)
    except Exception as e:
        parser.error(e)

def create_option_parser():
    """Define command-line options, usage notes and so forth."""
    parser = optparse.OptionParser(
            version="release-1 (2009-12-11)",
            usage="%prog [OPTIONS] SOURCE LINK_SOURCE DESTINATION",
            description="create a snapshot-style backup with NTFS hardlinks")

    def attr_option_handler(option, opt_str, value, parser):
        if any(c not in option.metavar for c in value.upper()):
            m = "{0} option requires one or more of characters in [{1}]"
            raise optparse.OptionValueError(m.format(opt_str, option.metavar))
        else:
            setattr(parser.values, option.dest, value.upper())

    copy_group = optparse.OptionGroup(parser, "Copy Options")
    copy_group.add_option("-l", "--list-only", dest="list_only",
            action="store_true", default=False,
            help="list only - don't copy or link anything")
    copy_group.add_option("--afa", "--add-file-attr", dest="add_file_attr",
            type="string", metavar="RASHNT", default="",
            action="callback", callback=attr_option_handler,
            help="add the given attributes to copied files")
    parser.add_option_group(copy_group)

    sel_group = optparse.OptionGroup(parser, "Selection Options")
    sel_group.add_option("--xfa", "--exclude-file-by-attr",
            dest="exclude_file_by_attr", type="string", metavar="RASHCNETO",
            default="", action="callback", callback=attr_option_handler,
            help="exclude files with any of the given attributes")
    sel_group.add_option("--xr", "--exclude-by-regexp",
            dest="exclude_by_regexp", action="append", type="string",
            metavar="PATTERN", default=[],
            help="exclude items matching regular expression PATTERN")
    sel_group.add_option("--xj", "--exclude-junctions",
            dest="exclude_junctions", action="store_true", default=False,
            help="exclude junctions (reparse points) - same as --xjd --xjf")
    sel_group.add_option("--xjd", "--exclude-dir-junctions",
            dest="exclude_dir_junctions", action="store_true", default=False,
            help="exclude junctions (reparse points) for directories")
    sel_group.add_option("--xjf", "--exclude-file-junctions",
            dest="exclude_file_junctions", action="store_true", default=False,
            help="exclude junctions (reparse points) for files")
    parser.add_option_group(sel_group)

    log_group = optparse.OptionGroup(parser, "Logging Options")
    log_group.add_option("--verbose", dest="verbose",
            action="store_true", default=False, help="enable verbose log")
    log_group.add_option("--utf8-log", dest="utf8_log", action="store_true",
            default=False, help="print log messages in UTF-8 encoding")
    log_group.add_option("--utf8-error", dest="utf8_error", action="store_true",
            default=False, help="print error messages in UTF-8 encoding")
    parser.add_option_group(log_group)

    return parser


def create_walker(src, lnk, dst, options):
    walker = Walker()
    walker.set_logger(create_logger(src, lnk, dst, options))
    walker.set_comparator(Comparator())
    walker.set_filter(create_filter(src, lnk, dst, options))
    walker.set_commander(create_commander(src, lnk, dst, options))
    return walker

def create_logger(src, lnk, dst, options):
    out_encoding = "utf-8" if options.utf8_log else sys.stdout.encoding
    out_stream = io.TextIOWrapper(sys.stdout.buffer,
            encoding=out_encoding, errors="backslashreplace", newline="")
    err_encoding = "utf-8" if options.utf8_error else sys.stdout.encoding
    err_stream = io.TextIOWrapper(sys.stderr.buffer,
            encoding=err_encoding, errors="backslashreplace", newline="")

    logger = Logger()
    logger.set_out_stream(out_stream)
    logger.set_err_stream(err_stream)
    logger.set_verbose(options.verbose)
    return logger

def create_filter(src, lnk, dst, options):
    filter = Filter()
    filter.set_destination(dst)
    filter.set_exclude_by_regexp(options.exclude_by_regexp)
    filter.set_exclude_file_by_attr(options.exclude_file_by_attr)
    filter.set_exclude_dir_junctions(
            options.exclude_junctions or options.exclude_dir_junctions)
    filter.set_exclude_file_junctions(
            options.exclude_junctions or options.exclude_file_junctions)
    return filter

def create_commander(src, lnk, dst, options):
    if options.list_only:
        return NullCommander()
    else:
        commander = RealCommander()
        if options.add_file_attr:
            commander.set_file_attr_to_add(options.add_file_attr)
        return commander


class Walker:
    def set_logger(self, value):
        self._logger = value
        return self

    def set_comparator(self, value):
        self._comparator = value
        return self

    def set_filter(self, value):
        self._filter = value
        return self

    def set_commander(self, value):
        self._commander = value
        return self

    def start_walk(self, src, lnk, dst):
        if self._filter.excludes_dir(src):
            self._logger.log_skip(src)
        else:
            if not os.path.isdir(dst):
                self._commander.make_dirs(dst)
            self._visit(src, lnk, dst)

    def _visit(self, src, lnk, dst):
        self._logger.log_dir(src)
        for item in os.listdir(src):
            src_item = os.path.join(src, item)
            lnk_item = os.path.join(lnk, item)
            dst_item = os.path.join(dst, item)

            try:
                if os.path.isdir(src_item):
                    if self._filter.excludes_dir(src_item):
                        self._logger.log_skip(src_item)
                    else:
                        self._commander.copy_dir(src_item, dst_item)
                        self._visit(src_item, lnk_item, dst_item)
                else:
                    if self._filter.excludes_file(src_item):
                        self._logger.log_skip(src_item)
                    else:
                        if self._comparator.is_same_file(src_item, lnk_item):
                            self._commander.link_file(lnk_item, dst_item)
                            self._logger.log_link(src_item)
                        else:
                            self._commander.copy_file(src_item, dst_item)
                            self._logger.log_copy(src_item)
            except Exception as e:
                # log and skip
                self._logger.error(src_item, e)


class Filter:
    _destination = ""
    _exclude_dir_junctions = False
    _exclude_file_junctions = False
    _exclude_by_regexp = []
    _exclude_file_by_attr = 0
    _dir_attr_to_exclude = 0
    _file_attr_to_exclude = 0

    def set_destination(self, value):
        self._destination = os.path.normpath(value)
        return self

    def set_exclude_file_by_attr(self, value):
        self._exclude_file_by_attr = str_attrs_to_bits(value)
        return self._update_attr_to_exclude()

    def set_exclude_dir_junctions(self, value):
        self._exclude_dir_junctions = bool(value)
        return self._update_attr_to_exclude()

    def set_exclude_file_junctions(self, value):
        self._exclude_file_junctions = bool(value)
        return self._update_attr_to_exclude()

    def set_exclude_by_regexp(self, value):
        self._exclude_by_regexp = []
        for pattern in value:
            if pattern.startswith("(?#casesensitive)"):
                self._exclude_by_regexp.append(re.compile(pattern))
            else:
                self._exclude_by_regexp.append(re.compile(pattern, re.I))
        return self

    def excludes_dir(self, path):
        """Return True if the directory is to be excluded."""
        path = os.path.normpath(path)
        if path == self._destination:
            return True

        if 0 < self._dir_attr_to_exclude:
            if self._has_attr(path, self._dir_attr_to_exclude):
                return True

        for pattern in self._exclude_by_regexp:
            if pattern.search(path) is not None:
                return True

        return False

    def excludes_file(self, path):
        """Return True if the file is to be excluded."""
        if 0 < self._file_attr_to_exclude:
            if self._has_attr(path, self._file_attr_to_exclude):
                return True

        for pattern in self._exclude_by_regexp:
            if pattern.search(path) is not None:
                return True

        return False

    def _has_attr(self, path, attr):
        return bool(attr & get_file_attributes(path))

    def _update_attr_to_exclude(self):
        self._dir_attr_to_exclude = 0
        if self._exclude_dir_junctions:
            self._dir_attr_to_exclude |= 0x400  # FILE_ATTRIBUTE_REPARSE_POINT
        self._file_attr_to_exclude = self._exclude_file_by_attr
        if self._exclude_file_junctions:
            self._file_attr_to_exclude |= 0x400 # FILE_ATTRIBUTE_REPARSE_POINT
        return self



class RealCommander:
    """The polymorphic proxy in charge of filesystem-changing operations."""
    _file_attr_to_add = 0

    def set_file_attr_to_add(self, value):
        self._file_attr_to_add = str_attrs_to_bits(value)
        return self

    def copy_dir(self, src, dst):
        os.mkdir(dst)
        set_file_attributes(dst, get_file_attributes(src))

    def make_dirs(self, path):
        os.makedirs(path)

    def copy_file(self, src, dst):
        if not ctypes.windll.kernel32.CopyFileW(src, dst, True):
            raise ctypes.WinError() # XXX The message is not nice.
        if 0 < self._file_attr_to_add:
            set_file_attributes(dst,
                    self._file_attr_to_add | get_file_attributes(dst))

    def link_file(self, src, dst):
        if not ctypes.windll.kernel32.CreateHardLinkW(dst, src, None):
            raise ctypes.WinError() # XXX The message is not nice.


class NullCommander:
    """The empty commander used for the --list-only option."""
    def copy_dir(self, src, dst):
        pass

    def make_dirs(self, path):
        pass

    def copy_file(self, src, dst):
        pass

    def link_file(self, src, dst):
        pass


class Comparator:
    def is_same_file(self, lft, rgt):
        op = os.path
        if op.isfile(lft) and op.isfile(rgt):
            if op.getmtime(lft) == op.getmtime(rgt):
                if op.getsize(lft) == op.getsize(rgt):
                    return True

        return False


class Logger:
    _verbose = False
    _out_stream = sys.stdout
    _err_stream = sys.stderr

    def set_verbose(self, value):
        self._verbose = bool(value)
        return self

    def set_out_stream(self, value):
        self._out_stream = value
        return self

    def set_err_stream(self, value):
        self._err_stream = value
        return self

    def log_link(self, path):
        print("Link", self._get_size(path), path,
                sep="\t", file=self._out_stream)

    def log_copy(self, path):
        print("Copy", self._get_size(path), path,
                sep="\t", file=self._out_stream)

    def log_skip(self, path):
        if self._verbose:
            print("Skip", "", path, sep="\t", file=self._out_stream)

    def log_dir(self, path):
        if self._verbose:
            print("Dir.", "", path, sep="\t", file=self._out_stream)

    def error(self, path, message):
        print(path, message, sep="\t", file=self._err_stream)
        if self._verbose:
            print("Err.", "", path, sep="\t", file=self._out_stream)

    def _get_size(self, path):
        """Return the size of file, suppressing errors."""
        try:
            return os.path.getsize(path)
        except OSError:
            return -1


# utility functions
def str_attrs_to_bits(str_attrs):
    attr_map = { "R": 0x1, "H": 0x2, "S": 0x4, "A": 0x20,
            "T": 0x100, "C": 0x800, "O": 0x1000, "N": 0x2000, "E": 0x4000 }
    bits = 0
    for c in str_attrs.upper():
        if c not in attr_map:
            return -1
        else:
            bits |= attr_map[c]
    return bits

def get_file_attributes(path):
    attributes = ctypes.windll.kernel32.GetFileAttributesW(path)
    if attributes == -1:    # -1 = INVALID_FILE_ATTRIBUTES
        raise ctypes.WinError()
    else:
        return attributes

def set_file_attributes(path, attributes):
    if not ctypes.windll.kernel32.SetFileAttributesW(path, attributes):
        raise ctypes.WinError()


if __name__ == "__main__":
    main(sys.argv[1:])
スポンサーサイト

関連記事

トラックバック URL

http://liosk.blog103.fc2.com/tb.php/192-abb4ca29

トラックバック

コメント

“Windows+ハードリンク+バックアップ”でGoogle検索して来ました。
凄く便利ですね。大事に使わせていただきます。
  • 2010-02-24
  • by Kon
  • id:9wIXYclI
  • 編集

コメントの投稿

お名前
コメント
編集キー
 
上記広告は1ヶ月以上更新のないブログに表示されています。新しい記事を書くことで広告を消せます。