Files
duplicati/Tools/Commandline/generate-dlist-files.py
T

579 lines
20 KiB
Python

#!/usr/bin/env python3
"""
Script to generate dlist files from Duplicati SQLite database.
This script reads from a Duplicati local backup database and generates
dlist (fileset) files for each backup version found.
Usage:
python generate_dlist_files.py <database_path> [options]
Example:
python generate_dlist_files.py /path/to/backup.sqlite --output-dir ./dlist_files
"""
import argparse
import json
import os
import sqlite3
import sys
import zipfile
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional, Tuple
# Constants matching Duplicati's format
SERIALIZED_DATE_TIME_FORMAT = "%Y%m%dT%H%M%SZ"
FILELIST_FILENAME = "filelist.json"
FILESET_FILENAME = "fileset"
MANIFEST_FILENAME = "manifest"
MANIFEST_VERSION = 2
MANIFEST_ENCODING = "utf8"
# Special blockset IDs
FOLDER_BLOCKSET_ID = -100
SYMLINK_BLOCKSET_ID = -200
# Unix epoch starts at 1970-01-01, .NET ticks start at 0001-01-01
# The difference is 621355968000000000 ticks
TICKS_TO_1970 = 621355968000000000
TICKS_PER_SECOND = 10000000
def convert_timestamp(ts: int) -> datetime:
"""
Convert a Duplicati timestamp to Python datetime.
Duplicati may store timestamps as:
- Unix seconds (values around 1-2 billion for dates in 2020s)
- Unix milliseconds (values around 1-2 trillion)
- .NET DateTime ticks (values around 6-7 quadrillion for dates in 2020s)
"""
if ts is None:
return datetime.now(timezone.utc)
# Detect format based on magnitude
if ts > TICKS_TO_1970:
# .NET DateTime ticks (100-nanosecond intervals since 0001-01-01)
unix_seconds = (ts - TICKS_TO_1970) / TICKS_PER_SECOND
return datetime.fromtimestamp(unix_seconds, tz=timezone.utc)
elif ts > 1000000000000: # 1 trillion
# Likely Unix milliseconds
return datetime.fromtimestamp(ts / 1000, tz=timezone.utc)
else:
# Likely Unix seconds
return datetime.fromtimestamp(ts, tz=timezone.utc)
def serialize_datetime(dt: datetime) -> str:
"""Serialize datetime to Duplicati format (UTC)."""
return dt.astimezone(timezone.utc).strftime(SERIALIZED_DATE_TIME_FORMAT)
def deserialize_datetime(s: str) -> datetime:
"""Deserialize datetime from Duplicati format."""
return datetime.strptime(s, SERIALIZED_DATE_TIME_FORMAT).replace(tzinfo=timezone.utc)
def generate_filename(
filetype: str,
prefix: str,
timestamp: datetime,
compression_module: str = "zip",
encryption_module: Optional[str] = None
) -> str:
"""Generate a dlist filename matching Duplicati's naming convention."""
volumename = f"{prefix}-{serialize_datetime(timestamp)}.{filetype}.{compression_module}"
if encryption_module:
volumename += f".{encryption_module}"
return volumename
def create_manifest_data(blocksize: int, blockhash: str, filehash: str) -> Dict[str, Any]:
"""Create manifest data matching Duplicati's format."""
return {
"Version": MANIFEST_VERSION,
"Created": serialize_datetime(datetime.now(timezone.utc)),
"Encoding": MANIFEST_ENCODING,
"Blocksize": blocksize,
"BlockHash": blockhash,
"FileHash": filehash,
"AppVersion": "2.0.0.0" # Placeholder
}
def create_fileset_data(is_full_backup: bool) -> Dict[str, Any]:
"""Create fileset data matching Duplicati's format."""
return {
"IsFullBackup": is_full_backup
}
class DuplicatiDatabase:
"""Wrapper for Duplicati SQLite database access."""
def __init__(self, db_path: str):
self.db_path = db_path
self.conn = sqlite3.connect(db_path)
self.conn.row_factory = sqlite3.Row
def close(self):
"""Close database connection."""
self.conn.close()
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.close()
def get_configuration(self) -> Dict[str, str]:
"""Get configuration settings from the database."""
cursor = self.conn.execute('SELECT "Key", "Value" FROM "Configuration"')
return {row["Key"]: row["Value"] for row in cursor.fetchall()}
def get_filesets(self) -> List[Dict[str, Any]]:
"""
Get all filesets (backup versions) from the database.
Returns list of dicts with: id, operation_id, volume_id, is_full_backup, timestamp
"""
cursor = self.conn.execute('''
SELECT
"ID" as id,
"OperationID" as operation_id,
"VolumeID" as volume_id,
"IsFullBackup" as is_full_backup,
"Timestamp" as timestamp
FROM "Fileset"
ORDER BY "Timestamp" DESC
''')
filesets = []
for row in cursor.fetchall():
filesets.append({
"id": row["id"],
"operation_id": row["operation_id"],
"volume_id": row["volume_id"],
"is_full_backup": bool(row["is_full_backup"]),
"timestamp": convert_timestamp(row["timestamp"])
})
return filesets
def get_files_for_fileset(self, fileset_id: int) -> List[Dict[str, Any]]:
"""
Get all files belonging to a specific fileset.
Returns list of file entries with all metadata needed for dlist.
"""
cursor = self.conn.execute('''
SELECT
f."Path" as path,
fe."Lastmodified" as lastmodified,
fl."BlocksetID" as blockset_id,
fl."MetadataID" as metadata_id,
CASE
WHEN fl."BlocksetID" = ? THEN 'Folder'
WHEN fl."BlocksetID" = ? THEN 'Symlink'
ELSE 'File'
END as entry_type
FROM "FilesetEntry" fe
JOIN "File" f ON fe."FileID" = f."ID"
JOIN "FileLookup" fl ON f."ID" = fl."ID"
WHERE fe."FilesetID" = ?
ORDER BY f."Path"
''', (FOLDER_BLOCKSET_ID, SYMLINK_BLOCKSET_ID, fileset_id))
files = []
for row in cursor.fetchall():
entry = {
"path": row["path"],
"lastmodified": convert_timestamp(row["lastmodified"]),
"blockset_id": row["blockset_id"],
"metadata_id": row["metadata_id"],
"type": row["entry_type"]
}
# Get file content info (for files and alternate streams)
if entry["type"] == "File":
content_info = self._get_blockset_info(entry["blockset_id"])
if content_info:
entry.update(content_info)
# Get metadata info
metadata_info = self._get_metadata_info(entry["metadata_id"])
if metadata_info:
entry["metadata"] = metadata_info
files.append(entry)
return files
def _get_blockset_info(self, blockset_id: int) -> Optional[Dict[str, Any]]:
"""Get blockset information (hash, size, blocklists)."""
if blockset_id is None or blockset_id <= 0:
return None
cursor = self.conn.execute('''
SELECT "Length" as length, "FullHash" as fullhash
FROM "Blockset"
WHERE "ID" = ?
''', (blockset_id,))
row = cursor.fetchone()
if not row:
return None
result = {
"size": row["length"],
"hash": row["fullhash"]
}
# Get blocklist hashes for large files (files with multiple blocks)
cursor = self.conn.execute('''
SELECT "Hash" as hash
FROM "BlocklistHash"
WHERE "BlocksetID" = ?
ORDER BY "Index"
''', (blockset_id,))
blocklist_hashes = [r["hash"] for r in cursor.fetchall()]
if blocklist_hashes:
result["blocklists"] = blocklist_hashes
else:
# Small file stored as single block - get block hash directly
cursor = self.conn.execute('''
SELECT b."Hash" as hash, b."Size" as size
FROM "BlocksetEntry" be
JOIN "Block" b ON be."BlockID" = b."ID"
WHERE be."BlocksetID" = ?
ORDER BY be."Index"
LIMIT 1
''', (blockset_id,))
block_row = cursor.fetchone()
if block_row:
result["blockhash"] = block_row["hash"]
result["blocksize"] = block_row["size"]
return result
def _get_metadata_info(self, metadata_id: int) -> Optional[Dict[str, Any]]:
"""Get metadata blockset information."""
if metadata_id is None or metadata_id <= 0:
return None
cursor = self.conn.execute('''
SELECT "BlocksetID" as blockset_id, "Content" as content
FROM "Metadataset"
WHERE "ID" = ?
''', (metadata_id,))
row = cursor.fetchone()
if not row:
return None
result = {}
# Get metadata blockset info
if row["blockset_id"]:
cursor = self.conn.execute('''
SELECT "Length" as length, "FullHash" as fullhash
FROM "Blockset"
WHERE "ID" = ?
''', (row["blockset_id"],))
bs_row = cursor.fetchone()
if bs_row:
result["metasize"] = bs_row["length"]
result["metahash"] = bs_row["fullhash"]
# Get metablocklist hashes
cursor = self.conn.execute('''
SELECT "Hash" as hash
FROM "BlocklistHash"
WHERE "BlocksetID" = ?
ORDER BY "Index"
''', (row["blockset_id"],))
metablocklist_hashes = [r["hash"] for r in cursor.fetchall()]
if metablocklist_hashes:
result["metablocklists"] = metablocklist_hashes
else:
# Small metadata stored as single block
cursor = self.conn.execute('''
SELECT b."Hash" as hash
FROM "BlocksetEntry" be
JOIN "Block" b ON be."BlockID" = b."ID"
WHERE be."BlocksetID" = ?
ORDER BY be."Index"
LIMIT 1
''', (row["blockset_id"],))
meta_block_row = cursor.fetchone()
if meta_block_row:
result["metablockhash"] = meta_block_row["hash"]
return result if result else None
def create_filelist_json(files: List[Dict[str, Any]]) -> str:
"""
Create the filelist.json content as a JSON array string.
Each file entry follows Duplicati's format:
{
"type": "File" | "Folder" | "Symlink" | "AlternateStream",
"path": "/path/to/file",
"hash": "abc123...",
"size": 12345,
"time": "20240115T120000Z",
"metahash": "def456...",
"metasize": 123,
"metablockhash": "ghi789..." | "metablocklists": ["hash1", "hash2"],
"blockhash": "jkl012...",
"blocksize": 102400,
"blocklists": ["hash1", "hash2"]
}
"""
entries = []
for file_info in files:
entry = {
"type": file_info["type"],
"path": file_info["path"]
}
# Add file-specific fields
if file_info["type"] in ("File", "AlternateStream"):
if "hash" in file_info:
entry["hash"] = file_info["hash"]
if "size" in file_info:
entry["size"] = file_info["size"]
if "lastmodified" in file_info:
entry["time"] = serialize_datetime(file_info["lastmodified"])
# Add block info (either blocklists for large files or single blockhash)
if "blocklists" in file_info:
entry["blocklists"] = file_info["blocklists"]
elif "blockhash" in file_info:
entry["blockhash"] = file_info["blockhash"]
entry["blocksize"] = file_info.get("blocksize", 0)
# Add metadata fields
if "metadata" in file_info and file_info["metadata"]:
meta = file_info["metadata"]
if "metahash" in meta:
entry["metahash"] = meta["metahash"]
if "metasize" in meta:
entry["metasize"] = meta["metasize"]
if "metablocklists" in meta:
entry["metablocklists"] = meta["metablocklists"]
elif "metablockhash" in meta:
entry["metablockhash"] = meta["metablockhash"]
entries.append(entry)
# Return as JSON array (without newlines between entries to match Duplicati's compact format)
return json.dumps(entries, separators=(',', ':'))
def generate_dlist_file(
output_path: str,
fileset: Dict[str, Any],
files: List[Dict[str, Any]],
config: Dict[str, str],
compression: str = "zip",
blocksize_override: Optional[int] = None,
blockhash_override: Optional[str] = None,
filehash_override: Optional[str] = None,
prefix_override: Optional[str] = None
) -> str:
"""
Generate a dlist file for a specific fileset.
Args:
output_path: Directory where the file will be written
fileset: Fileset metadata (id, timestamp, is_full_backup, etc.)
files: List of file entries for this fileset
config: Database configuration (blocksize, blockhash, filehash)
compression: Compression module to use (default: zip)
blocksize_override: Optional override for blocksize from command line
blockhash_override: Optional override for blockhash from command line
filehash_override: Optional override for filehash from command line
prefix_override: Optional override for prefix from command line
Returns:
Path to the generated file
"""
# Get configuration values with defaults, allowing command-line overrides
blocksize = blocksize_override if blocksize_override is not None else int(config.get("blocksize", "102400"))
blockhash = blockhash_override if blockhash_override is not None else config.get("block-hash-algorithm", "SHA256")
filehash = filehash_override if filehash_override is not None else config.get("file-hash-algorithm", "SHA256")
prefix = prefix_override if prefix_override is not None else config.get("prefix", "duplicati")
# Generate filename
filename = generate_filename(
"dlist",
prefix,
fileset["timestamp"],
compression
)
filepath = os.path.join(output_path, filename)
# Create the zip file
with zipfile.ZipFile(filepath, 'w', compression=zipfile.ZIP_DEFLATED) as zf:
# Add manifest file
manifest_data = create_manifest_data(blocksize, blockhash, filehash)
zf.writestr(MANIFEST_FILENAME, json.dumps(manifest_data))
# Add fileset file
fileset_data = create_fileset_data(fileset["is_full_backup"])
zf.writestr(FILESET_FILENAME, json.dumps(fileset_data))
# Add filelist.json
filelist_json = create_filelist_json(files)
zf.writestr(FILELIST_FILENAME, filelist_json)
return filepath
def main():
parser = argparse.ArgumentParser(
description="Generate dlist files from Duplicati SQLite database"
)
parser.add_argument(
"database",
help="Path to the Duplicati SQLite database file"
)
parser.add_argument(
"--output-dir", "-o",
default=".",
help="Output directory for generated dlist files (default: current directory)"
)
parser.add_argument(
"--compression", "-c",
default="zip",
help="Compression format: zip (default)"
)
parser.add_argument(
"--fileset-id", "-f",
type=int,
help="Generate only for specific fileset ID (default: all filesets)"
)
parser.add_argument(
"--list", "-l",
action="store_true",
help="List filesets without generating files"
)
parser.add_argument(
"--blocksize", "-b",
type=int,
help="Block size in bytes (default: read from database, or 102400)"
)
parser.add_argument(
"--blockhash",
help="Block hash algorithm (default: read from database, or SHA256)"
)
parser.add_argument(
"--filehash",
help="File hash algorithm (default: read from database, or SHA256)"
)
parser.add_argument(
"--prefix", "-p",
help="Filename prefix (default: read from database, or 'duplicati')"
)
args = parser.parse_args()
# Validate database path
if not os.path.exists(args.database):
print(f"Error: Database file not found: {args.database}", file=sys.stderr)
sys.exit(1)
# Create output directory if needed
if not args.list and not os.path.exists(args.output_dir):
os.makedirs(args.output_dir)
# Open database
with DuplicatiDatabase(args.database) as db:
# Get configuration
config = db.get_configuration()
# Get all filesets
filesets = db.get_filesets()
if not filesets:
print("No filesets found in database.")
sys.exit(0)
# List mode
if args.list:
print(f"\nFound {len(filesets)} fileset(s) in database:")
print("-" * 80)
for fs in filesets:
backup_type = "FULL" if fs["is_full_backup"] else "PARTIAL"
print(f" ID: {fs['id']}")
print(f" Timestamp: {fs['timestamp'].isoformat()}")
print(f" Type: {backup_type}")
print(f" Volume ID: {fs['volume_id']}")
print("-" * 80)
return
# Filter to specific fileset if requested
if args.fileset_id is not None:
filesets = [fs for fs in filesets if fs["id"] == args.fileset_id]
if not filesets:
print(f"Error: Fileset ID {args.fileset_id} not found", file=sys.stderr)
sys.exit(1)
# Show configuration being used
print(f"\nGenerating dlist files for {len(filesets)} fileset(s)...")
print(f"Output directory: {os.path.abspath(args.output_dir)}")
print("-" * 80)
print("Configuration:")
blocksize = args.blocksize if args.blocksize is not None else int(config.get("blocksize", "102400"))
blockhash = args.blockhash if args.blockhash is not None else config.get("block-hash-algorithm", "SHA256")
filehash = args.filehash if args.filehash is not None else config.get("file-hash-algorithm", "SHA256")
prefix = args.prefix if args.prefix is not None else config.get("prefix", "duplicati")
print(f" Blocksize: {blocksize} bytes {'(from command line)' if args.blocksize else '(from database)'}")
print(f" Blockhash: {blockhash} {'(from command line)' if args.blockhash else '(from database)'}")
print(f" Filehash: {filehash} {'(from command line)' if args.filehash else '(from database)'}")
print(f" Prefix: {prefix} {'(from command line)' if args.prefix else '(from database)'}")
print("-" * 80)
generated_files = []
for fileset in filesets:
print(f"\nProcessing fileset ID {fileset['id']} ({fileset['timestamp'].isoformat()})...")
# Get files for this fileset
files = db.get_files_for_fileset(fileset["id"])
print(f" Found {len(files)} files")
# Generate dlist file
filepath = generate_dlist_file(
args.output_dir,
fileset,
files,
config,
args.compression,
blocksize_override=args.blocksize,
blockhash_override=args.blockhash,
filehash_override=args.filehash,
prefix_override=args.prefix
)
generated_files.append(filepath)
file_size = os.path.getsize(filepath)
print(f" Generated: {os.path.basename(filepath)}")
print(f" Size: {file_size:,} bytes")
print("\n" + "=" * 80)
print(f"Successfully generated {len(generated_files)} dlist file(s):")
for f in generated_files:
print(f" - {f}")
if __name__ == "__main__":
main()