diff --git a/CHANGELOG.md b/CHANGELOG.md index 028cc7b..406c7c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ ## Unreleased — 0.1.2 +- Added the production libudev/libmtp generation adapter with bounded peer + selection, object transfer and manifest-last publication; wired the trusted + orchestrator mode and proved the full Firefox/Android/desktop conversation + with an I/O-boundary object double. Added complete versioned Android + backup/verify/restore and a reproducible, non-installed schema-v17 bridge + candidate with v17 round-trip and v17-to-v20 migration tests. Physical MTP, + signing, device installation, user-data operations and rollout remain + pending. + - Added opt-in foreground generation coordination, peer-evidenced capabilities, truthful bounded Web progress, descendant cleanup, an integrated Android ↔ desktop Firefox proof, and synthetic candidate/SQLite recovery tooling on the diff --git a/android/app/src/main/java/com/labfytools/trainlog/data/AndroidBackupService.kt b/android/app/src/main/java/com/labfytools/trainlog/data/AndroidBackupService.kt new file mode 100644 index 0000000..e35e525 --- /dev/null +++ b/android/app/src/main/java/com/labfytools/trainlog/data/AndroidBackupService.kt @@ -0,0 +1,176 @@ +package com.labfytools.trainlog.data + +import android.content.Context +import android.database.sqlite.SQLiteDatabase +import java.io.File +import java.io.InputStream +import java.io.OutputStream +import java.security.MessageDigest +import java.time.OffsetDateTime +import java.util.zip.ZipEntry +import java.util.zip.ZipInputStream +import java.util.zip.ZipOutputStream +import org.json.JSONArray +import org.json.JSONObject + +internal sealed interface AndroidBackupResult { + data class Success(val bytes: Long) : AndroidBackupResult + data class Error(val message: String) : AndroidBackupResult +} + +internal data class VerifiedAndroidBackup(val directory: File, val database: File, val preferences: File) + +/** Complete same-installation backup container; plaintext is explicit by design. */ +internal class AndroidBackupService(private val context: Context) { + companion object { + const val FORMAT = "trainlog-android-backup" + const val VERSION = 1 + const val MAX_ENTRY_BYTES = 768L * 1024L * 1024L + const val MAX_TOTAL_BYTES = 1024L * 1024L * 1024L + private val PREFERENCES = listOf("trainlog-sync", "trainlog_presentation_settings") + } + + private fun digest(file: File): String { + val value = MessageDigest.getInstance("SHA-256") + file.inputStream().use { input -> + val buffer = ByteArray(64 * 1024) + while (true) { val count = input.read(buffer); if (count < 0) break; value.update(buffer, 0, count) } + } + return value.digest().joinToString("") { "%02x".format(it) } + } + + private fun preferencesJson(): String = JSONObject().apply { + put("format", "trainlog-android-preferences"); put("version", 1) + put("stores", JSONObject().also { stores -> PREFERENCES.forEach { name -> + stores.put(name, JSONObject().also { values -> + context.getSharedPreferences(name, Context.MODE_PRIVATE).all.toSortedMap().forEach { (key, value) -> + when (value) { + null -> values.put(key, JSONObject.NULL) + is String, is Boolean, is Int, is Long, is Float -> values.put(key, value) + is Set<*> -> values.put(key, JSONArray(value.filterIsInstance().sorted())) + else -> error("Unsupported preference type") + } + } + }) + } }) + }.toString() + + fun createPrivate(repository: TrainlogRepository): Result = runCatching { + val directory = File(context.filesDir, "backups").apply { mkdirs() } + val archive = File(directory, "trainlog-backup-${java.util.UUID.randomUUID()}.tlbackup") + archive.outputStream().use { destination -> + val result = writeArchive(repository, destination) + if (result is AndroidBackupResult.Error) error(result.message) + } + archive + } + + fun create(repository: TrainlogRepository, destination: OutputStream): AndroidBackupResult { + val archive = createPrivate(repository).getOrElse { return AndroidBackupResult.Error(it.message ?: "Backup failed.") } + return try { archive.inputStream().use { it.copyTo(destination) }; destination.flush() + val bytes = archive.length(); archive.delete(); AndroidBackupResult.Success(bytes) + } catch (error: Exception) { + AndroidBackupResult.Error("Export failed; private backup retained at ${archive.name}: ${error.message}") + } + } + + private fun writeArchive(repository: TrainlogRepository, destination: OutputStream): AndroidBackupResult { + val stage = File(context.cacheDir, "backup-${java.util.UUID.randomUUID()}") + if (!stage.mkdir()) return AndroidBackupResult.Error("Cannot create private backup staging.") + val database = File(stage, "database.sqlite"); val preferences = File(stage, "preferences.json") + return try { + repository.backupDatabaseTo(database); preferences.writeText(preferencesJson()) + val schema = validateDatabase(database) + val files = listOf("database.sqlite" to database, "preferences.json" to preferences) + val manifest = JSONObject().put("format", FORMAT).put("version", VERSION) + .put("package", context.packageName).put("schema", schema).put("product_version", "0.1.2") + .put("plaintext", true).put("created_at", OffsetDateTime.now().toString()) + .put("entries", JSONArray(files.map { (name, file) -> JSONObject().put("path", name) + .put("size", file.length()).put("sha256", digest(file)) })) + var written = 0L + ZipOutputStream(destination).use { zip -> + files.forEach { (name, file) -> zip.putNextEntry(ZipEntry(name)); file.inputStream().use { input -> written += input.copyTo(zip) }; zip.closeEntry() } + zip.putNextEntry(ZipEntry("manifest.json")); val raw = manifest.toString().toByteArray(); zip.write(raw); written += raw.size; zip.closeEntry() + } + AndroidBackupResult.Success(written) + } catch (error: Exception) { AndroidBackupResult.Error(error.message ?: "Backup failed.") + } finally { stage.deleteRecursively() } + } + + fun verify(input: InputStream): Result { + val stage = File(context.cacheDir, "restore-${java.util.UUID.randomUUID()}") + if (!stage.mkdir()) return Result.failure(IllegalStateException("Cannot create restore staging.")) + return runCatching { + var total = 0L; val names = mutableSetOf() + ZipInputStream(input).use { zip -> + while (true) { + val entry = zip.nextEntry ?: break + require(!entry.isDirectory && entry.name in setOf("database.sqlite", "preferences.json", "manifest.json") && names.add(entry.name)) { "Invalid backup entry." } + val target = File(stage, entry.name); target.outputStream().use { output -> + val buffer = ByteArray(64 * 1024); var entryBytes = 0L + while (true) { val count = zip.read(buffer); if (count < 0) break; entryBytes += count; total += count + require(entryBytes <= MAX_ENTRY_BYTES && total <= MAX_TOTAL_BYTES) { "Backup exceeds bounds." }; output.write(buffer, 0, count) } + } + } + } + require(names == setOf("database.sqlite", "preferences.json", "manifest.json")) { "Backup entries are incomplete." } + val manifest = JSONObject(File(stage, "manifest.json").readText()) + require(manifest.getString("format") == FORMAT && manifest.getInt("version") == VERSION && + manifest.getString("package") == context.packageName && manifest.getBoolean("plaintext") && + manifest.getInt("schema") in 17..20) { "Unsupported backup." } + val expected = manifest.getJSONArray("entries") + require(expected.length() == 2) + val manifestPaths = mutableSetOf() + for (index in 0 until expected.length()) { val item = expected.getJSONObject(index); val path = item.getString("path") + require(path in setOf("database.sqlite", "preferences.json") && manifestPaths.add(path)) { "Invalid manifest entry." }; val file = File(stage, path) + require(file.canonicalFile.parentFile == stage.canonicalFile && file.length() == item.getLong("size") && digest(file) == item.getString("sha256")) { "Backup digest mismatch." } } + require(manifestPaths == setOf("database.sqlite", "preferences.json")) { "Backup manifest is incomplete." } + require(validateDatabase(File(stage, "database.sqlite")) == manifest.getInt("schema")) + val preferences = JSONObject(File(stage, "preferences.json").readText()) + require(preferences.getString("format") == "trainlog-android-preferences" && preferences.getInt("version") == 1) + VerifiedAndroidBackup(stage, File(stage, "database.sqlite"), File(stage, "preferences.json")) + }.onFailure { stage.deleteRecursively() } + } + + fun restore(repository: TrainlogRepository, verified: VerifiedAndroidBackup, databaseName: String = "trainlog-android.db"): AndroidBackupResult { + val destination = context.getDatabasePath(databaseName) + val previous = File(destination.parentFile, "${destination.name}.pre-restore-${java.util.UUID.randomUUID()}") + val previousPreferences = File(context.cacheDir, "preferences-pre-restore-${java.util.UUID.randomUUID()}.json") + var moved = false + return try { + previousPreferences.writeText(preferencesJson()) + repository.close(); destination.parentFile?.mkdirs() + if (destination.exists()) { check(destination.renameTo(previous)); moved = true } + File("${destination.path}-wal").delete(); File("${destination.path}-shm").delete() + verified.database.copyTo(destination, overwrite = false) + validateDatabase(destination); restorePreferences(verified.preferences) + previous.delete(); previousPreferences.delete(); verified.directory.deleteRecursively() + AndroidBackupResult.Success(destination.length()) + } catch (error: Exception) { + destination.delete() + if (moved) previous.renameTo(destination) + if (previousPreferences.isFile) runCatching { restorePreferences(previousPreferences) } + previousPreferences.delete() + AndroidBackupResult.Error(error.message ?: "Restore failed.") + } + } + + private fun validateDatabase(file: File): Int { + val db = SQLiteDatabase.openDatabase(file.path, null, SQLiteDatabase.OPEN_READONLY) + try { require(db.rawQuery("PRAGMA integrity_check", null).use { it.moveToFirst() && it.getString(0) == "ok" }) + require(db.rawQuery("PRAGMA foreign_key_check", null).use { !it.moveToFirst() }) + require(db.version in 17..20) { "Unsupported Android schema ${db.version}." } + return db.version + } finally { db.close() } + } + + private fun restorePreferences(file: File) { + val stores = JSONObject(file.readText()).getJSONObject("stores") + PREFERENCES.forEach { name -> val editor = context.getSharedPreferences(name, Context.MODE_PRIVATE).edit().clear(); val values = stores.getJSONObject(name) + values.keys().forEach { key -> when (val value = values.get(key)) { + is String -> editor.putString(key, value); is Boolean -> editor.putBoolean(key, value) + is Int -> editor.putInt(key, value); is Long -> editor.putLong(key, value) + is Double -> editor.putFloat(key, value.toFloat()); is JSONArray -> editor.putStringSet(key, (0 until value.length()).map { value.getString(it) }.toSet()) + } }; check(editor.commit()) } + } +} diff --git a/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt b/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt index af1b850..37ce918 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt @@ -40,6 +40,7 @@ import com.labfytools.trainlog.model.TrackingMode import org.json.JSONArray import org.json.JSONObject import java.text.Normalizer +import java.io.File import java.io.StringReader import java.nio.ByteBuffer import java.nio.charset.StandardCharsets @@ -443,6 +444,13 @@ class TrainlogRepository( database.close() } + /** Create a transactionally consistent SQLite snapshot without exposing WAL files. */ + internal fun backupDatabaseTo(destination: File) { + require(!destination.exists()) { "Backup snapshot destination already exists." } + val escaped = destination.absolutePath.replace("'", "''") + database.writableDatabase.execSQL("VACUUM INTO '$escaped'") + } + /** * Runs generation work in one SQLite scope. WHY: independently opened * exporter/importer connections cannot prove a coherent generation. diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/SectionScreens.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/SectionScreens.kt index 59bcfd4..45bcf12 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/ui/SectionScreens.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/SectionScreens.kt @@ -31,6 +31,8 @@ import com.labfytools.trainlog.data.KnowledgeConfidence import com.labfytools.trainlog.data.SyncCatalogInbox import com.labfytools.trainlog.data.StartAiSessionDraftResult import com.labfytools.trainlog.data.TrainlogRepository +import com.labfytools.trainlog.data.AndroidBackupService +import com.labfytools.trainlog.data.AndroidBackupResult import com.labfytools.trainlog.data.directStoragePermissionIntent import com.labfytools.trainlog.model.ActiveSessionDraft import com.labfytools.trainlog.ui.theme.LocalTrainlogColors @@ -275,7 +277,7 @@ private fun catalogCountsText(context: android.content.Context, imported: Int, r ).joinToString(", ") @Composable -fun SettingsScreen(inbox: SyncCatalogInbox, onCatalogChanged: () -> Unit) { +fun SettingsScreen(repository: TrainlogRepository, inbox: SyncCatalogInbox, onCatalogChanged: () -> Unit) { val colors = LocalTrainlogColors.current val context = LocalContext.current val language = LocalLanguagePresentation.current @@ -298,6 +300,25 @@ fun SettingsScreen(inbox: SyncCatalogInbox, onCatalogChanged: () -> Unit) { } } } + val backupService = remember { AndroidBackupService(context) } + val backupLauncher = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("application/zip")) { uri -> + if (uri != null) { + val result = runCatching { context.contentResolver.openOutputStream(uri, "w")!!.use { backupService.create(repository, it) } }.getOrElse { AndroidBackupResult.Error(it.message ?: "Backup export failed.") } + message = strings.getString(if (result is AndroidBackupResult.Success) R.string.settings_backup_complete else R.string.settings_backup_failed) + } + } + val restoreLauncher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> + if (uri != null) { + val verified = runCatching { context.contentResolver.openInputStream(uri)!!.use { backupService.verify(it).getOrThrow() } } + val result = verified.fold({ backupService.restore(repository, it) }, { AndroidBackupResult.Error(it.message ?: "Backup verification failed.") }) + if (result is AndroidBackupResult.Error) { + android.widget.Toast.makeText(context, strings.getString(R.string.settings_restore_failed), android.widget.Toast.LENGTH_LONG).show() + } + // Restore closes the repository before replacement. Recreate even + // after a rolled-back failure so no UI owner retains that handle. + (context as? android.app.Activity)?.recreate() + } + } TrainlogScreen(strings.getString(R.string.settings_screen)) { TrainlogFrame(strings.getString(R.string.settings_language_section)) { TrainlogInfo("${strings.getString(R.string.settings_language_label)} : " + @@ -331,5 +352,12 @@ fun SettingsScreen(inbox: SyncCatalogInbox, onCatalogChanged: () -> Unit) { }) message?.let { TrainlogInfo(it, if (authorized) colors.success else colors.error) } } + TrainlogFrame(strings.getString(R.string.settings_backup_section)) { + TrainlogInfo(strings.getString(R.string.settings_backup_plaintext)) + TrainlogAction(strings.getString(R.string.settings_backup_create), strings.getString(R.string.settings_backup_create_description), + { backupLauncher.launch("trainlog-backup.tlbackup") }) + TrainlogAction(strings.getString(R.string.settings_backup_restore), strings.getString(R.string.settings_backup_restore_description), + { restoreLauncher.launch(arrayOf("application/zip", "application/octet-stream")) }) + } } } diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/TrainlogApp.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/TrainlogApp.kt index 28d870c..ecd8ad7 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/ui/TrainlogApp.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/TrainlogApp.kt @@ -140,7 +140,7 @@ fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: S AppRoute.BodyMeasurements -> BodyScreen(repository, bodyState, { exportSnapshot() }, { back() }) AppRoute.LatestMaxima -> LatestMaximaScreen(repository) AppRoute.Sync -> SyncScreen(repository, inbox, exporter, requestOutbox, { exportSnapshot(); catalogRevision++ }, { back() }) - AppRoute.Settings -> SettingsScreen(inbox) { exportSnapshot(); catalogRevision++ } + AppRoute.Settings -> SettingsScreen(repository, inbox) { exportSnapshot(); catalogRevision++ } }} } diff --git a/android/app/src/main/res/values-en/strings.xml b/android/app/src/main/res/values-en/strings.xml index a52a1de..dfbf59c 100644 --- a/android/app/src/main/res/values-en/strings.xml +++ b/android/app/src/main/res/values-en/strings.xml @@ -396,4 +396,13 @@ Session order saved. Reorder was not saved: %1$s Enter a valid decimal value using “,” or “.”. + Complete local backup + The file contains plaintext health data. Keep it in a private location. + Create backup + Export the complete database, active draft, and preferences. + Restore backup + Verify and restore this installation. Synchronization must remain stopped. + Complete backup exported. + Backup failed; any private staged copy was retained. + The backup is invalid or could not be restored. diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index b1c7c5e..24f3075 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -396,4 +396,13 @@ Ordre de la séance enregistré. Réorganisation non enregistrée : %1$s Saisissez une valeur décimale valide avec « , » ou « . ». + Sauvegarde locale complète + Le fichier contient vos données de santé en clair. Conservez-le dans un emplacement privé. + Créer une sauvegarde + Exporter la base complète, le brouillon actif et les préférences. + Restaurer une sauvegarde + Vérifier puis restaurer cette installation. La synchronisation doit rester arrêtée. + Sauvegarde complète exportée. + Échec de la sauvegarde ; toute copie privée créée est conservée. + Sauvegarde invalide ou restauration impossible. diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidBackupServiceTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidBackupServiceTest.kt new file mode 100644 index 0000000..bb48b62 --- /dev/null +++ b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidBackupServiceTest.kt @@ -0,0 +1,62 @@ +package com.labfytools.trainlog.data + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import com.labfytools.trainlog.model.ActiveSessionDraft +import com.labfytools.trainlog.model.NewExerciseProfile +import com.labfytools.trainlog.model.RecordingMode +import com.labfytools.trainlog.model.SessionExerciseDraft +import com.labfytools.trainlog.model.SessionDraftForm +import com.labfytools.trainlog.model.SessionSetDraft +import com.labfytools.trainlog.model.TrackingMode +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.util.UUID +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class AndroidBackupServiceTest { + private val context: Context = ApplicationProvider.getApplicationContext() + + @Test fun completeBackupRestoresDraftPeerLedgersAndPreferences() { + val name = "backup-${UUID.randomUUID()}.db" + var repository = TrainlogRepository(context, name) + val service = AndroidBackupService(context) + try { + val exercise = (repository.createExercise(NewExerciseProfile("Backup exercise", + RecordingMode.SETS, TrackingMode.REPS, 0)) as CreateExerciseResult.Created).exercise + assertEquals(ActiveDraftMutationResult.Saved, repository.saveActiveSessionDraft(ActiveSessionDraft( + exercises = listOf(SessionExerciseDraft(exercise = exercise, + sets = listOf(SessionSetDraft(reps = 9, weightKg = 31.5)))), + form = SessionDraftForm(selectedExercise = exercise, weightText = "31,")))) + val peer = SyncGenerationService(repository).peerId() + context.getSharedPreferences("trainlog_presentation_settings", Context.MODE_PRIVATE) + .edit().putString("language", "en").commit() + val output = ByteArrayOutputStream() + assertTrue(service.create(repository, output) is AndroidBackupResult.Success) + val verified = service.verify(ByteArrayInputStream(output.toByteArray())).getOrThrow() + assertTrue(service.restore(repository, verified, name) is AndroidBackupResult.Success) + repository = TrainlogRepository(context, name) + val draft = repository.loadActiveSessionDraft() + assertTrue(draft is ActiveDraftLoadResult.Loaded) + assertEquals("31,", (draft as ActiveDraftLoadResult.Loaded).draft.form.weightText) + assertEquals(peer, SyncGenerationService(repository).peerId()) + assertEquals("en", context.getSharedPreferences("trainlog_presentation_settings", Context.MODE_PRIVATE).getString("language", null)) + } finally { repository.close(); context.deleteDatabase(name) } + } + + @Test fun corruptAndTraversalContainersAreRejectedBeforeRestore() { + val service = AndroidBackupService(context) + val output = ByteArrayOutputStream() + java.util.zip.ZipOutputStream(output).use { zip -> + zip.putNextEntry(java.util.zip.ZipEntry("../database.sqlite")); zip.write(byteArrayOf(1)); zip.closeEntry() + } + assertTrue(service.verify(ByteArrayInputStream(output.toByteArray())).isFailure) + } +} diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/BridgeBackupMigrationTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/BridgeBackupMigrationTest.kt new file mode 100644 index 0000000..979ea93 --- /dev/null +++ b/android/app/src/test/java/com/labfytools/trainlog/data/BridgeBackupMigrationTest.kt @@ -0,0 +1,31 @@ +package com.labfytools.trainlog.data + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import java.io.FileInputStream +import java.io.File +import java.util.UUID +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Assume.assumeTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) @Config(sdk=[35]) +class BridgeBackupMigrationTest { + @Test fun verifiedSchemaSeventeenBridgeBackupMigratesThroughCurrentOwner() { + val source=System.getenv("TRAINLOG_BRIDGE_BACKUP_INPUT")?.let(::File) + assumeTrue(source?.isFile==true) + val context:Context=ApplicationProvider.getApplicationContext();val name="bridge-migrate-${UUID.randomUUID()}.db" + val service=AndroidBackupService(context);var repository=TrainlogRepository(context,name) + try { + val verified=FileInputStream(source!!).use { service.verify(it).getOrThrow() } + assertTrue(service.restore(repository,verified,name) is AndroidBackupResult.Success) + repository=TrainlogRepository(context,name) + assertEquals("21,",(repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded).draft.form.weightText) + android.database.sqlite.SQLiteDatabase.openDatabase(context.getDatabasePath(name).path,null,android.database.sqlite.SQLiteDatabase.OPEN_READONLY).use { assertEquals(20,it.version) } + } finally {repository.close();context.deleteDatabase(name)} + } +} diff --git a/docs/android.md b/docs/android.md index 3c88519..23a4227 100644 --- a/docs/android.md +++ b/docs/android.md @@ -936,3 +936,23 @@ domains plus the durable generation record in one outer transaction; and replays the stored logical ACK after restart. The complete-envelope context is internal and typed: standalone artifact methods retain their causal guards. This service is not selected by the automatic V3 storage/request path. + +## Complete local backup and restore + +Settings exposes an explicit plaintext `.tlbackup` export and restore flow for +the same application identity. The versioned ZIP contains a SQLite-consistent +`VACUUM INTO` snapshot, active drafts and raw partial form input, synchronization +peer/generation/causal state, and the owned presentation/sync preferences. A +manifest written last records schema, package, sizes and SHA-256 digests. +Verification stages only the three exact bounded entries, validates integrity, +foreign keys, package, supported schema and digests before closing the live +repository. Restore retains the previous database and preferences until the +replacement validates; failure restores both. This is not an interchange +format and is intentionally unencrypted, so the UI warns the user to protect +the exported file. + +The installed schema-v17 release has no backup UI. The reproducible +`tools/build_android_backup_bridge.py` recipe materializes that exact source, +adds only the backup surface, assigns a higher candidate version code and +builds/tests an APK without installing it. Its synthetic v17 archive is also +restored by current code and migrated through the normal v17-to-v20 path. diff --git a/docs/architecture.md b/docs/architecture.md index b6bc8b8..414eedc 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -430,6 +430,15 @@ transport only from a trusted local configuration. The HTTP event loop only admits work and reads a bounded durable report. V3 remains the default when the trusted generation configuration is absent. +In `mtp` mode the worker invokes one fixed packaged helper. Its production +callbacks are the existing libudev/libmtp object operations; tests replace only +that typed I/O table. Candidate selection requires one exact stable peer +advertisement and required capabilities. Downloads are bounded and size +checked. Upload retries accept byte-identical objects, and each generation +manifest is published after its member objects. The helper runs inside the +orchestrator-owned process group and cannot be selected or parameterized by an +HTTP request. + It polls for a new Android request and invokes the same shared C synchronization engine used by the TUI. diff --git a/docs/current_state.md b/docs/current_state.md index 1f52b49..be9e822 100644 --- a/docs/current_state.md +++ b/docs/current_state.md @@ -1,9 +1,12 @@ # Current implementation state The development branch has opt-in full-generation coordination with durable Web -progress and a foreground Android conversation. The cross-implementation -directory-I/O-double proof is integrated. Physical MTP, release signing, user -backups, main integration and deployment are not complete. +progress and a foreground Android conversation. Its production generation MTP +adapter is integrated and tested through an object-I/O boundary double. Android +also has a complete same-installation backup/verify/restore container, and a +non-installed schema-v17 bridge candidate is reproducibly built and tested. +Physical-device MTP, release signing, real user backup/restore, main integration +and deployment are not complete. Snapshot date: **2026-09-17**. @@ -63,6 +66,7 @@ parallel implementation of its rules. | Synchronization data/lifecycle slice | `TRAINLOG_SYNC_DATA_LIFECYCLE_V1=PASS/FROZEN` | | Synchronization causal deletion slice | `TRAINLOG_SYNC_CAUSAL_DELETE_V1=PASS/FROZEN` | | Synchronization generation/ACK slice | `TRAINLOG_SYNC_GENERATION_ACK_V1=PASS/FROZEN` (explicit staged entry points; active transport remains V3) | +| Generation MTP and Android backup | `TRAINLOG_SYNC_MTP_AND_ANDROID_BACKUP_V1=SOFTWARE_COMPLETE` (physical-device and user-operation gates remain) | Desktop and Android schema numbers are independent. Neither changes the frozen Trainlog JSON V1 contract. @@ -107,8 +111,9 @@ Android inbox/outbox still select V3 and do not move the draft artifact. few domain-specific explicit states make defined replay paths idempotent. Explicit staged generation services now provide coherent V4/companion capture, immutable manifests, whole-generation SQLite consumption and - durable peer ACKs. The active automatic path remains V3 and does not select - these services. + durable peer ACKs. A trusted opt-in configuration may select the bounded + production generation MTP adapter; absent configuration leaves V3 as the + default. - SQLite database files are never synchronized. - The desktop AI flow uses external `rclone` for Drive inbox/archive and read-only history export. Android owns no Drive credentials. diff --git a/docs/design/sync_deployment_readiness_v1_evidence.md b/docs/design/sync_deployment_readiness_v1_evidence.md index bf01e5f..7553cc6 100644 --- a/docs/design/sync_deployment_readiness_v1_evidence.md +++ b/docs/design/sync_deployment_readiness_v1_evidence.md @@ -1,6 +1,7 @@ # Sync deployment readiness V1 evidence -Status: **SOFTWARE BLOCKED — physical production MTP adapter not completed**. +Status: **SUPERSEDED SOFTWARE BLOCKER — adapter and backup completed by the +MTP/Android-backup follow-up; physical and rollout gates remain**. This corrective tranche replaces the earlier configurable peer fixture with a fixed desktop worker and a production Android foreground coordinator. The real diff --git a/docs/design/sync_mtp_and_android_backup_v1_evidence.md b/docs/design/sync_mtp_and_android_backup_v1_evidence.md new file mode 100644 index 0000000..c6f0251 --- /dev/null +++ b/docs/design/sync_mtp_and_android_backup_v1_evidence.md @@ -0,0 +1,26 @@ +# Sync MTP and Android backup V1 evidence + +Status: **SOFTWARE COMPLETE — hardware and user-operation gates pending**. + +The production C adapter resolves the direct-MTP object tree and is selected +only by trusted `mode: mtp` configuration. Unit tests substitute its typed +libudev/libmtp callbacks, not its selection, bounds, download, retry or +manifest-last publication logic. The integrated headless-Firefox proof uses the +same algorithm over an object-store double and completes the real Android and +desktop generation/ACK conversation. + +Android creates a SQLite-consistent, versioned plaintext archive containing the +database and owned preferences, verifies exact entries, bounds, hashes, +integrity, foreign keys, package and schema before replacement, and retains the +old database/preferences until restore verification succeeds. The pinned +schema-v17 bridge candidate builds without installation, passes its v17 +round-trip test, and produces an archive that current Android restores and +migrates to schema v20. + +Software evidence includes the Meson transport test, orchestrator process and +configuration tests, Android backup tests, bridge/current migration chain, +Android debug assembly, full isolated validation harness, and candidate +inventory verification. Physical MTP with an unlocked phone, release-signature +continuity, bridge/current APK installation, real user backup/restore, service +changes, main integration and deployment were not performed and require their +separate approvals. diff --git a/docs/roadmap.md b/docs/roadmap.md index 93a2175..194a2a2 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -143,7 +143,11 @@ the separate execution-draft codec are implemented behind explicit entry points, while active transport remains V3. Causal deletion is implemented and validated through explicit staged entry points, including both real producer directions for every contracted target kind; generation/consumption -acknowledgement, orchestration/reporting and Web sync remain unimplemented. +acknowledgement, orchestration/reporting, Web sync, the production generation +MTP adapter and complete Android backup/restore are implemented on the +development branch. Remaining gates are physical-device validation, signed +bridge/current APK procedures, real user backups and an explicitly authorized +rollout. `WEB_DASHBOARD_V1=PASS/FROZEN` remains closed and unchanged. ### Gym catalog V1 diff --git a/docs/sync_exchange.md b/docs/sync_exchange.md index 3031110..4f41dc7 100644 --- a/docs/sync_exchange.md +++ b/docs/sync_exchange.md @@ -745,8 +745,9 @@ between passes and the three explicit unclassified states remained intact. ## 18. Staged coherent generations and durable acknowledgement `tools/sync_generation_exchange.py` and Android `SyncGenerationService` are -the production staged owners for manifest V1 and ACK V1. Neither is wired into -the automatic V3 MTP selection. Both allocate stable installation peer IDs and +the production staged owners for manifest V1 and ACK V1. The trusted opt-in +orchestrator can carry their object tree through the production direct-MTP +adapter; automatic V3 selection remains unchanged. Both allocate stable installation peer IDs and opaque UUIDv4 generation/run IDs only in the explicitly opened staged store. Desktop capture uses one private SQLite backup and runs every established @@ -775,6 +776,15 @@ idempotently. Causal V1 operation bytes remain unchanged across first emission and retransmission because generation membership lives only in `sync_causal_publications`. +The generation MTP adapter resolves `Documents/Trainlog` through libudev and +libmtp, selects one exact advertised peer/capability set across bounded devices +and storages, and mirrors only bounded regular object names. It downloads to +private temporary files before replacement, verifies reported and received +sizes, rejects ambiguous peers, traversal, excess depth/count/bytes and +truncation, and publishes generation manifests last. Production behavior is +tested by replacing only the typed libmtp/udev I/O callbacks with a filesystem +object double; the same compiled transport algorithm remains under test. + ## 19. Legacy active-protocol limitations The Web end-to-end tranche adds an explicit opt-in application orchestrator. diff --git a/docs/sync_rollout_readiness.md b/docs/sync_rollout_readiness.md index 8cfd4ec..9d03eac 100644 --- a/docs/sync_rollout_readiness.md +++ b/docs/sync_rollout_readiness.md @@ -10,9 +10,12 @@ bounded typed progress and its descendant process group. The foreground Android coordinator advertises its real peer identity, publishes manifest-last bytes, consumes the return generation and emits a durable correlated ACK. -The integrated proof uses a directory object-I/O double. It is not evidence for -physical libmtp behavior. The shipped physical generation adapter is not yet -complete, so generation mode must not be enabled on services. +The integrated proof uses the production generation adapter with only its +typed libudev/libmtp I/O callbacks replaced by an object-store double. This is +software evidence for selection, bounds, publication ordering and the complete +conversation, but not evidence for a particular phone's physical libmtp +behavior. Generation mode must remain disabled until that hardware gate and +the separately authorized rollout are complete. ### Candidate, backup and recovery @@ -25,10 +28,12 @@ complete, so generation mode must not be enabled on services. 3. Quiesce old writers only during an authorized rollout. Desktop backup uses `backup_trainlog_sqlite.py` and SQLite's backup API, then integrity and foreign-key checks. -4. V3 is not an Android backup and omits active-draft and causal state. No safe - backup path for the installed release was demonstrated without device access. - This is a hard gate: an authorized procedure must capture the app database, - WAL-consistent state and active draft before update. +4. V3 is not an Android backup. Current Android has a complete verified backup + container. For the installed schema-v17 release, build the pinned bridge + candidate, verify signing/version continuity separately, install only with + explicit approval, create and verify the user backup, and retain it before + installing current Android. None of those device/user operations occurred in + this software mission. 5. Replacing a binary does not roll back a schema. Before any exchange, a mutually compatible captured pair may be restored. After causal writes or ACKs, never restore only one peer: stop synchronization, preserve both stores diff --git a/docs/tests.md b/docs/tests.md index 3610bff..527a768 100644 --- a/docs/tests.md +++ b/docs/tests.md @@ -1289,3 +1289,21 @@ production-service suite. Android adds `importedCausalOperationsCannotRetireBuiltInIdentities`; the existing session round trip now also changes and reverts a confirmed set before proving the remote deletion conflicts without losing business or causal state. + +## Generation MTP and Android backup + +`generation_mtp` drives the production transport algorithm through typed fake +libudev/libmtp callbacks. It covers exact peer selection, bounded download, +manifest-last publication, ambiguity and truncated-object rejection. The +Firefox end-to-end scenario also runs in `mtp` mode: only the callback table is +replaced by a filesystem object store while the C transport, Python worker, +Android generation service, desktop consumers and durable ACK path remain real. + +`AndroidBackupServiceTest` creates and restores a populated current-schema +backup including an active draft, raw partial input, stable peer identity and +preferences, and rejects corrupt and traversal entries. The bridge build runs +the analogous test on the exact schema-v17 source and feeds its archive to +`BridgeBackupMigrationTest`, which verifies normal migration to schema v20. +These are software proofs. They do not claim physical USB/MTP behavior, +installation over the signed release, a real user backup, or a real-device +restore. diff --git a/tests/test_sync_orchestrator.py b/tests/test_sync_orchestrator.py index 144cc66..a5fcce1 100644 --- a/tests/test_sync_orchestrator.py +++ b/tests/test_sync_orchestrator.py @@ -17,7 +17,7 @@ class OrchestratorTest(unittest.TestCase): db,state,config,run,request=values return subprocess.run(["python3",str(executable),"--database",str(db),"--state",str(state),"--config",str(config),"--run-id",run,"--request-id",request],capture_output=True,text=True) def test_browser_configuration_cannot_select_command_or_boolean_timeout(self): - for override in ({"peer_command":["evil"]},{"timeout_seconds":True},{"mode":"mtp"}): + for override in ({"peer_command":["evil"]},{"timeout_seconds":True},{"mode":"unsupported"}): with self.subTest(override=override),tempfile.TemporaryDirectory() as directory: root=Path(directory);values=self.fixture(root,**override);result=self.invoke(ORCHESTRATOR,values) self.assertEqual(result.returncode,2);self.assertEqual(json.loads(values[1].read_text())["phase"],"failed") diff --git a/tests/test_web_sync_browser.py b/tests/test_web_sync_browser.py index 9ef6d56..54d2dd2 100644 --- a/tests/test_web_sync_browser.py +++ b/tests/test_web_sync_browser.py @@ -23,21 +23,25 @@ class BrowserSyncTest(unittest.TestCase): from selenium.webdriver.support.ui import WebDriverWait with tempfile.TemporaryDirectory(prefix="trainlog-web-sync-") as directory: root=Path(directory);xdg=root/"data";xdg.mkdir();config=root/"config.json" + mode=os.environ.get("TRAINLOG_WEB_E2E_TRANSPORT_MODE","directory") transport=root/"transport";transport.mkdir();owned=root/"owned";owned.mkdir() + remote=(root/"device/Documents/Trainlog") if mode=="mtp" else transport + remote.mkdir(parents=True,exist_ok=True) release=root/"release-android";android_log=(root/"android.log").open("wb") - android_env=os.environ.copy();android_env.update({"JAVA_HOME":"/usr/lib/jvm/java-17-openjdk","TRAINLOG_SYNC_TRANSPORT_ROOT":str(transport),"TRAINLOG_SYNC_TEST_RELEASE_FILE":str(release)}) + android_env=os.environ.copy();android_env.update({"JAVA_HOME":"/usr/lib/jvm/java-17-openjdk","TRAINLOG_SYNC_TRANSPORT_ROOT":str(remote),"TRAINLOG_SYNC_TEST_RELEASE_FILE":str(release)}) android=subprocess.Popen(["./gradlew","--no-daemon","testDebugUnitTest","--tests","com.labfytools.trainlog.data.SyncDeploymentConversationTest","--rerun-tasks"],cwd=ROOT/"android",env=android_env,stdout=android_log,stderr=subprocess.STDOUT,start_new_session=True) try: - peer_file=await_condition(lambda: (transport/"android-peer-v1.json") if (transport/"android-peer-v1.json").is_file() else None,120) + peer_file=await_condition(lambda: (remote/"android-peer-v1.json") if (remote/"android-peer-v1.json").is_file() else None,120) except Exception as error: if android.poll() is None: os.killpg(android.pid,signal.SIGTERM) android.wait(timeout=10);android_log.close() raise AssertionError(f"Android bridge did not advertise:\n{(root/'android.log').read_text(errors='replace')}") from error peer=json.loads(peer_file.read_text()) - config.write_text(json.dumps({"format":"trainlog-sync-orchestrator-config","version":1,"enabled":True,"mode":"directory","expected_peer_id":peer["peer_id"],"transport_root":str(transport),"owned_root":str(owned),"timeout_seconds":30})) + config.write_text(json.dumps({"format":"trainlog-sync-orchestrator-config","version":1,"enabled":True,"mode":mode,"expected_peer_id":peer["peer_id"],"transport_root":str(transport),"owned_root":str(owned),"timeout_seconds":60})) with socket.socket() as reserved: reserved.bind(("127.0.0.1",0));port=reserved.getsockname()[1] env=os.environ.copy();env.update({"XDG_DATA_HOME":str(xdg),"XDG_CONFIG_HOME":str(root/"config"),"XDG_CACHE_HOME":str(root/"cache"),"TRAINLOG_SYNC_GENERATION_CONFIG":str(config)}) + if mode=="mtp": env.update({"TRAINLOG_SYNC_MTP_ADAPTER":str(ROOT/"build/tui/generation-mtp-adapter-double"),"TRAINLOG_MTP_DOUBLE_ROOT":str(root/"device")}) server=subprocess.Popen([str(ROOT/"build/tui/trainlog"),"--web","--port",str(port)],env=env,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,start_new_session=True) driver=None try: @@ -52,7 +56,9 @@ class BrowserSyncTest(unittest.TestCase): button=wait.until(lambda d:d.find_element(By.XPATH,"//button[normalize-space()='Synchroniser']")) wait.until(lambda _ : button.is_enabled());button.click() state_path=Path(str(xdg/"trainlog"/"trainlog.db")+".sync-run.json") - committed=await_condition(lambda: json.loads(state_path.read_text()) if state_path.is_file() and json.loads(state_path.read_text()).get("phase")=="local_import_committed" else None) + observed=await_condition(lambda: json.loads(state_path.read_text()) if state_path.is_file() and json.loads(state_path.read_text()).get("phase") in ("local_import_committed","failed","interrupted") else None,70) + self.assertEqual(observed["phase"],"local_import_committed",f"state={observed}\nandroid={(root/'android.log').read_text(errors='replace')}") + committed=observed self.assertEqual(committed["result"],"running") with sqlite3.connect(xdg/"trainlog"/"trainlog.db") as database: self.assertEqual(database.execute("SELECT COUNT(*) FROM execution_drafts").fetchone()[0],1) diff --git a/tools/android_backup_bridge/BackupBridgeActivity.kt b/tools/android_backup_bridge/BackupBridgeActivity.kt new file mode 100644 index 0000000..bfee3eb --- /dev/null +++ b/tools/android_backup_bridge/BackupBridgeActivity.kt @@ -0,0 +1,37 @@ +package com.labfytools.trainlog.data + +import android.app.Activity +import android.content.Intent +import android.os.Bundle +import android.widget.Button +import android.widget.LinearLayout +import android.widget.TextView + +/** Minimal non-migrating bridge UI; it intentionally exposes backup/restore only. */ +class BackupBridgeActivity : Activity() { + private lateinit var repository: TrainlogRepository + private lateinit var status: TextView + private val service by lazy { AndroidBackupService(applicationContext) } + override fun onCreate(state: Bundle?) { + super.onCreate(state); repository = TrainlogRepository(applicationContext) + status = TextView(this).apply { text = "Plaintext local backup. Keep the exported file private." } + val backup = Button(this).apply { text = "Create complete backup"; setOnClickListener { + startActivityForResult(Intent(Intent.ACTION_CREATE_DOCUMENT).setType("application/zip") + .putExtra(Intent.EXTRA_TITLE, "trainlog-backup.tlbackup").addCategory(Intent.CATEGORY_OPENABLE), 1) } } + val restore = Button(this).apply { text = "Verify and restore backup"; setOnClickListener { + startActivityForResult(Intent(Intent.ACTION_OPEN_DOCUMENT).setType("application/zip") + .addCategory(Intent.CATEGORY_OPENABLE), 2) } } + setContentView(LinearLayout(this).apply { orientation = LinearLayout.VERTICAL; addView(status); addView(backup); addView(restore) }) + } + override fun onActivityResult(request: Int, result: Int, data: Intent?) { + super.onActivityResult(request, result, data); if (result != RESULT_OK || data?.data == null) return + if (request == 1) contentResolver.openOutputStream(data.data!!, "w")!!.use { + status.text = service.create(repository, it).toString() + } else if (request == 2) contentResolver.openInputStream(data.data!!)!!.use { + val verified = service.verify(it).getOrElse { error -> status.text = error.message; return } + status.text = service.restore(repository, verified).toString() + recreate() + } + } + override fun onDestroy() { repository.close(); super.onDestroy() } +} diff --git a/tools/android_backup_bridge/BackupBridgeServiceTest.kt b/tools/android_backup_bridge/BackupBridgeServiceTest.kt new file mode 100644 index 0000000..b393a6b --- /dev/null +++ b/tools/android_backup_bridge/BackupBridgeServiceTest.kt @@ -0,0 +1,44 @@ +package com.labfytools.trainlog.data + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import com.labfytools.trainlog.model.ActiveSessionDraft +import com.labfytools.trainlog.model.NewExerciseProfile +import com.labfytools.trainlog.model.RecordingMode +import com.labfytools.trainlog.model.SessionDraftForm +import com.labfytools.trainlog.model.SessionExerciseDraft +import com.labfytools.trainlog.model.SessionSetDraft +import com.labfytools.trainlog.model.TrackingMode +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.util.UUID +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) @Config(sdk=[35]) +class BackupBridgeServiceTest { + private val context: Context = ApplicationProvider.getApplicationContext() + @Test fun schemaSeventeenWalDraftRoundTripsWithoutMigration() { + val name="bridge-${UUID.randomUUID()}.db";var repository=TrainlogRepository(context,name);val service=AndroidBackupService(context) + try { + val exercise=(repository.createExercise(NewExerciseProfile("Bridge",RecordingMode.SETS,TrackingMode.REPS,0)) as CreateExerciseResult.Created).exercise + assertEquals(ActiveDraftMutationResult.Saved,repository.saveActiveSessionDraft(ActiveSessionDraft( + exercises=listOf(SessionExerciseDraft(exercise=exercise,sets=listOf(SessionSetDraft(reps=7,weightKg=20.0)))), + form=SessionDraftForm(selectedExercise=exercise,weightText="20,")))) + context.getDatabasePath(name).let { android.database.sqlite.SQLiteDatabase.openDatabase(it.path,null,android.database.sqlite.SQLiteDatabase.OPEN_READWRITE).use { db -> + db.enableWriteAheadLogging();db.execSQL("UPDATE active_session_draft SET weight_text='21,' WHERE id=1") + assertEquals(17,db.version) + } } + val bytes=ByteArrayOutputStream();assertTrue(service.create(repository,bytes) is AndroidBackupResult.Success) + System.getenv("TRAINLOG_BRIDGE_BACKUP_OUTPUT")?.let { java.io.File(it).writeBytes(bytes.toByteArray()) } + val verified=service.verify(ByteArrayInputStream(bytes.toByteArray())).getOrThrow() + assertTrue(service.restore(repository,verified,name) is AndroidBackupResult.Success) + repository=TrainlogRepository(context,name);assertEquals("21,",(repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded).draft.form.weightText) + context.getDatabasePath(name).let { android.database.sqlite.SQLiteDatabase.openDatabase(it.path,null,android.database.sqlite.SQLiteDatabase.OPEN_READONLY).use { db -> assertEquals(17,db.version) } } + } finally {repository.close();context.deleteDatabase(name)} + } +} diff --git a/tools/build_android_backup_bridge.py b/tools/build_android_backup_bridge.py new file mode 100644 index 0000000..6d87340 --- /dev/null +++ b/tools/build_android_backup_bridge.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 +"""Materialize and build the non-migrating schema-v17 Android backup bridge.""" +import argparse, io, os, shutil, subprocess, tarfile +from pathlib import Path + +ROOT=Path(__file__).resolve().parents[1] +BASE="77c15172e1401b91b8e2537c4b8548095ed50053" + +def replace(path:Path,old:str,new:str): + value=path.read_text() + if value.count(old)!=1: raise RuntimeError(f"bridge patch anchor mismatch: {path}: {old[:40]}") + path.write_text(value.replace(old,new)) + +def main(): + parser=argparse.ArgumentParser();parser.add_argument("--output",required=True,type=Path);parser.add_argument("--jdk",default="/usr/lib/jvm/java-17-openjdk") + args=parser.parse_args() + if args.output.exists(): raise RuntimeError("bridge output already exists") + args.output.mkdir(parents=True) + raw=subprocess.check_output(["git","archive","--format=tar",BASE,"android","catalog"],cwd=ROOT) + with tarfile.open(fileobj=io.BytesIO(raw)) as archive: + for member in archive.getmembers(): + target=(args.output/member.name).resolve() + if args.output.resolve() not in target.parents: raise RuntimeError("unsafe archive member") + archive.extractall(args.output,filter="data") + android=args.output/"android" + repository=android/"app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt" + replace(repository,"import java.text.Normalizer","import java.text.Normalizer\nimport java.io.File") + replace(repository," fun close() {\n database.close()\n }"," fun close() {\n database.close()\n }\n\n internal fun backupDatabaseTo(destination: File) {\n require(!destination.exists())\n val escaped = destination.absolutePath.replace(\"'\", \"''\")\n database.writableDatabase.execSQL(\"VACUUM INTO '$escaped'\")\n }") + shutil.copy2(ROOT/"android/app/src/main/java/com/labfytools/trainlog/data/AndroidBackupService.kt",android/"app/src/main/java/com/labfytools/trainlog/data/AndroidBackupService.kt") + shutil.copy2(ROOT/"tools/android_backup_bridge/BackupBridgeActivity.kt",android/"app/src/main/java/com/labfytools/trainlog/data/BackupBridgeActivity.kt") + shutil.copy2(ROOT/"tools/android_backup_bridge/BackupBridgeServiceTest.kt",android/"app/src/test/java/com/labfytools/trainlog/data/BackupBridgeServiceTest.kt") + manifest=android/"app/src/main/AndroidManifest.xml" + replace(manifest," \n \n \n int: if args.output.exists(): raise RuntimeError("candidate output already exists") binary = args.build / "tui/trainlog" + mtp_adapter = args.build / "tui/trainlog-generation-mtp-adapter" if not binary.is_file(): raise RuntimeError("compiled trainlog executable is missing") + if not mtp_adapter.is_file(): + raise RuntimeError("compiled generation MTP adapter is missing") (args.output / "bin").mkdir(parents=True) (args.output / "libexec").mkdir(parents=True) (args.output / "tools").mkdir(parents=True) (args.output / "catalog").mkdir(parents=True) shutil.copy2(binary, args.output / "libexec/trainlog") + shutil.copy2(mtp_adapter, args.output / "libexec/trainlog-generation-mtp-adapter") for name in RUNTIME_TOOLS: shutil.copy2(ROOT / "tools" / name, args.output / "tools" / name) for source in sorted((ROOT / "catalog").glob("*.json")): shutil.copy2(source, args.output / "catalog" / source.name) launcher = args.output / "bin/trainlog" - launcher.write_text("#!/bin/sh\nset -eu\nroot=$(CDPATH= cd -- \"$(dirname -- \"$0\")/..\" && pwd)\nexport TRAINLOG_SYNC_TOOLS_DIR=\"$root/tools\"\nexec \"$root/libexec/trainlog\" \"$@\"\n") + launcher.write_text("#!/bin/sh\nset -eu\nroot=$(CDPATH= cd -- \"$(dirname -- \"$0\")/..\" && pwd)\nexport TRAINLOG_SYNC_TOOLS_DIR=\"$root/tools\"\nexport TRAINLOG_SYNC_MTP_ADAPTER=\"$root/libexec/trainlog-generation-mtp-adapter\"\nexec \"$root/libexec/trainlog\" \"$@\"\n") files = sorted(path for path in args.output.rglob("*") if path.is_file()) inventory = { "format": "trainlog-sync-candidate-inventory", "version": 1, @@ -58,13 +62,15 @@ def main() -> int: "product_version": "0.1.2", "desktop_schema": 21, "android_schema": 20, "protocols": ["mobile-export-v3", "mobile-history-v4", "causal-delete-v1", "execution-draft-v1", "generation-manifest-v1", "generation-ack-v1"], - "entry_points": ["bin/trainlog", "tools/sync_orchestrator.py"], + "entry_points": ["bin/trainlog", "libexec/trainlog-generation-mtp-adapter", + "tools/sync_orchestrator.py"], "runtime_dependencies": ["python>=3.11", "sqlite3", "utf8proc", "libuuid", "libudev", "libmtp", "notcurses"], "files": [{"path": str(path.relative_to(args.output)), "sha256": digest(path), "size": path.stat().st_size} for path in files], } (args.output / "candidate-inventory.json").write_text(json.dumps(inventory, indent=2, sort_keys=True) + "\n") os.chmod(args.output / "bin/trainlog", 0o755) os.chmod(args.output / "libexec/trainlog", 0o755) + os.chmod(args.output / "libexec/trainlog-generation-mtp-adapter", 0o755) return 0 diff --git a/tools/sync_orchestrator.py b/tools/sync_orchestrator.py index 140664b..4c52c6e 100644 --- a/tools/sync_orchestrator.py +++ b/tools/sync_orchestrator.py @@ -171,8 +171,8 @@ def run(args: argparse.Namespace) -> int: raise RuntimeError("invalid trusted sync configuration") if config["format"] != "trainlog-sync-orchestrator-config" or config["version"] != 1 or config["enabled"] is not True: raise RuntimeError("full-generation synchronization is disabled") - if config["mode"] != "directory": - raise RuntimeError("physical MTP generation transport is not implemented") + if config["mode"] not in ("directory", "mtp"): + raise RuntimeError("invalid transport mode") for key in ("transport_root","owned_root"): if not isinstance(config[key],str) or not Path(config[key]).is_absolute(): raise RuntimeError("trusted paths must be absolute") if not isinstance(config["expected_peer_id"],str) or not re.fullmatch(r"peer_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}",config["expected_peer_id"]): raise RuntimeError("invalid expected peer identity") @@ -193,7 +193,13 @@ def run(args: argparse.Namespace) -> int: environment.update({"TRAINLOG_SYNC_RUN_ID": args.run_id, "TRAINLOG_SYNC_TRIGGER": state["trigger"], "TRAINLOG_SYNC_DATABASE": args.database}) - command=[sys.executable,str(Path(__file__).with_name("sync_peer_worker.py")),"--database",args.database,"--transport-root",config["transport_root"],"--owned-root",config["owned_root"],"--run-id",args.run_id,"--expected-peer",config["expected_peer_id"],"--timeout",str(timeout)] + command=[sys.executable,str(Path(__file__).with_name("sync_peer_worker.py")),"--database",args.database,"--transport-root",config["transport_root"],"--owned-root",config["owned_root"],"--run-id",args.run_id,"--expected-peer",config["expected_peer_id"],"--timeout",str(timeout),"--mode",config["mode"]] + if config["mode"] == "mtp": + adapter = os.environ.get("TRAINLOG_SYNC_MTP_ADAPTER", + str(Path(__file__).resolve().parents[1] / "build/tui/trainlog-generation-mtp-adapter")) + if not Path(adapter).is_absolute() or not os.access(adapter, os.X_OK): + raise RuntimeError("fixed MTP adapter executable is unavailable") + command.extend(["--mtp-adapter", adapter]) child = subprocess.Popen(command, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=environment, preexec_fn=_child_setup) diff --git a/tools/sync_peer_worker.py b/tools/sync_peer_worker.py index b19fa29..1bbcceb 100644 --- a/tools/sync_peer_worker.py +++ b/tools/sync_peer_worker.py @@ -32,9 +32,30 @@ def publish_json(path:Path,value:dict)->None: stream.write(generation.canonical(value));stream.flush();os.fsync(stream.fileno()) os.chmod(temporary,0o600);os.replace(temporary,path) -def wait_file(path:Path,deadline:float)->None: +def run_adapter(adapter:Path,operation:str,peer:str,root:Path,deadline:float,allow_missing_peer:bool=False)->bool: + remaining=max(1,int(deadline-time.monotonic())) + result=__import__("subprocess").run([str(adapter),operation,peer,str(root)],stdin=__import__("subprocess").DEVNULL, + capture_output=True,timeout=min(remaining,30),check=False) + if result.returncode!=0: + diagnostic=(result.stderr.decode("utf-8","replace")[:1024] or "MTP adapter failed").strip() + if allow_missing_peer and "expected peer was not found" in diagnostic: return False + raise RuntimeError(diagnostic) + return True + +def wait_file(path:Path,deadline:float,pump=None)->None: while not path.is_file(): if time.monotonic()>=deadline: raise RuntimeError(f"timeout waiting for {path.name}") + if pump is not None: pump() + time.sleep(.05) + +def wait_json(path:Path,deadline:float,predicate,pump=None)->dict: + while True: + wait_file(path,deadline,pump) + try: value=json.loads(bounded(path)) + except (OSError,json.JSONDecodeError): value=None + if isinstance(value,dict) and predicate(value): return value + if time.monotonic()>=deadline: raise RuntimeError(f"timeout waiting for correlated {path.name}") + if pump is not None: pump() time.sleep(.05) def load_peer(root:Path,expected:str)->dict: @@ -44,18 +65,29 @@ def load_peer(root:Path,expected:str)->dict: return value def main()->int: - parser=argparse.ArgumentParser();parser.add_argument("--database",required=True,type=Path);parser.add_argument("--transport-root",required=True,type=Path);parser.add_argument("--owned-root",required=True,type=Path);parser.add_argument("--run-id",required=True);parser.add_argument("--expected-peer",required=True);parser.add_argument("--timeout",required=True,type=int) + parser=argparse.ArgumentParser();parser.add_argument("--database",required=True,type=Path);parser.add_argument("--transport-root",required=True,type=Path);parser.add_argument("--owned-root",required=True,type=Path);parser.add_argument("--run-id",required=True);parser.add_argument("--expected-peer",required=True);parser.add_argument("--timeout",required=True,type=int);parser.add_argument("--mode",choices=("directory","mtp"),default="directory");parser.add_argument("--mtp-adapter",type=Path) args=parser.parse_args();deadline=time.monotonic()+args.timeout + pump=None + if args.mode=="mtp": + if args.mtp_adapter is None: raise RuntimeError("MTP adapter is required") + pump=lambda:run_adapter(args.mtp_adapter,"pull",args.expected_peer,args.transport_root,deadline,True) + # Android publishes its durable identity only while participating. A + # missing expected advertisement is therefore a bounded wait state; + # device, transport, ambiguity and malformed-object failures remain + # immediate and distinct adapter errors. + wait_file(args.transport_root/"android-peer-v1.json",deadline,pump) peer=load_peer(args.transport_root,args.expected_peer) with closing(generation.connect_database(args.database)) as db: generation.require_schema(db);desktop_peer=generation.peer_identity(db,"desktop");db.commit() request={"format":"trainlog-sync-generation-request","version":1,"run_id":args.run_id,"desktop_peer_id":desktop_peer,"android_peer_id":peer["peer_id"],"capabilities":sorted(CAPS)} request_path=args.transport_root/"request-v1.json";publish_json(request_path,request) + if args.mode=="mtp": run_adapter(args.mtp_adapter,"push",args.expected_peer,args.transport_root,deadline) emit(args.run_id,"waiting_android_publication",producer_peer_id=peer["peer_id"],consumer_peer_id=desktop_peer) - inbound_ref=args.transport_root/"android-generation-v1.json";wait_file(inbound_ref,deadline) - inbound=json.loads(bounded(inbound_ref));inbound_dir=args.transport_root/inbound["relative_path"] + inbound_ref=args.transport_root/"android-generation-v1.json" + inbound=wait_json(inbound_ref,deadline,lambda value:value.get("run_id")==args.run_id,pump);inbound_dir=args.transport_root/inbound["relative_path"] ack=generation.consume_desktop(args.database,inbound_dir) ack_path=args.transport_root/"desktop-consumption-ack-v1.json";publish_json(ack_path,ack) + if args.mode=="mtp": run_adapter(args.mtp_adapter,"push",args.expected_peer,args.transport_root,deadline) if ack["result"]!="consumed": raise RuntimeError("desktop rejected Android generation") emit(args.run_id,"local_import_committed",inbound_generation_id=ack["generation_id"],manifest_sha256=ack["manifest_sha256"]) outgoing_id="gen_"+str(uuid.uuid4()) @@ -68,10 +100,11 @@ def main()->int: published=generation.publish(args.database,outgoing_id,args.transport_root/"desktop-objects") ref={"format":"trainlog-sync-generation-reference","version":1,"run_id":args.run_id,"generation_id":outgoing_id,"manifest_sha256":digest,"relative_path":str(published.relative_to(args.transport_root))} publish_json(args.transport_root/"desktop-generation-v1.json",ref) + if args.mode=="mtp": run_adapter(args.mtp_adapter,"push",args.expected_peer,args.transport_root,deadline) emit(args.run_id,"published",outbound_generation_id=outgoing_id,manifest_sha256=digest) emit(args.run_id,"waiting_acknowledgement",outbound_generation_id=outgoing_id) - android_ack=args.transport_root/"android-consumption-ack-v1.json";wait_file(android_ack,deadline) - android_ack_value=json.loads(bounded(android_ack)) + android_ack=args.transport_root/"android-consumption-ack-v1.json" + android_ack_value=wait_json(android_ack,deadline,lambda value:value.get("run_id")==args.run_id and value.get("generation_id")==outgoing_id,pump) result=generation.accept_ack(args.database,android_ack) if result not in ("acknowledged","unchanged"): raise RuntimeError(f"Android did not durably consume desktop generation: {result}: {android_ack_value.get('diagnostic','')}") with closing(generation.connect_database(args.database)) as db: diff --git a/tui/include/trainlog/generation_mtp.h b/tui/include/trainlog/generation_mtp.h new file mode 100644 index 0000000..e435006 --- /dev/null +++ b/tui/include/trainlog/generation_mtp.h @@ -0,0 +1,35 @@ +#ifndef TRAINLOG_GENERATION_MTP_H +#define TRAINLOG_GENERATION_MTP_H + +#include +#include +#include +#include "trainlog/mtp.h" +#include "trainlog/status.h" +#include "trainlog/usb.h" + +#define TRAINLOG_GENERATION_MTP_MAX_DEVICES 8U +#define TRAINLOG_GENERATION_MTP_MAX_STORAGES 8U +#define TRAINLOG_GENERATION_MTP_MAX_CHILDREN 512U +#define TRAINLOG_GENERATION_MTP_MAX_DEPTH 5U +#define TRAINLOG_GENERATION_MTP_MAX_TOTAL_BYTES (512ULL * 1024ULL * 1024ULL) + +typedef struct TrainlogGenerationMtpIo { + TrainlogStatus (*devices)(TrainlogUsbDevice *, size_t, size_t *); + TrainlogStatus (*storages)(unsigned int, unsigned int, TrainlogMtpStorage *, size_t, size_t *); + TrainlogStatus (*children)(unsigned int, unsigned int, uint32_t, uint32_t, TrainlogMtpEntry *, size_t, size_t *); + TrainlogStatus (*ensure_folder)(unsigned int, unsigned int, uint32_t, uint32_t, const char *, uint32_t *, bool *); + TrainlogStatus (*receive)(unsigned int, unsigned int, uint32_t, const char *); + TrainlogStatus (*send)(unsigned int, unsigned int, uint32_t, uint32_t, const char *, const char *, uint32_t *); + TrainlogStatus (*remove)(unsigned int, unsigned int, uint32_t); + TrainlogStatus (*rename)(unsigned int, unsigned int, uint32_t, const char *); +} TrainlogGenerationMtpIo; + +/** Mirror remote generation-protocol objects to or from a private local root. */ +TrainlogStatus trainlog_generation_mtp_pull(const TrainlogGenerationMtpIo *io, + const char *expected_peer_id, const char *local_root, char *diagnostic, size_t diagnostic_size); +TrainlogStatus trainlog_generation_mtp_push(const TrainlogGenerationMtpIo *io, + const char *expected_peer_id, const char *local_root, char *diagnostic, size_t diagnostic_size); +const TrainlogGenerationMtpIo *trainlog_generation_mtp_production_io(void); + +#endif diff --git a/tui/include/trainlog/mtp.h b/tui/include/trainlog/mtp.h index 3d17a80..367d9ab 100644 --- a/tui/include/trainlog/mtp.h +++ b/tui/include/trainlog/mtp.h @@ -59,6 +59,23 @@ TrainlogStatus trainlog_mtp_ensure_root_folder( bool *output_created ); +/** + * @brief Find or create one exact folder below an explicit parent. + * + * Duplicate names are rejected as a conflict. The returned object identifier + * is valid only for the current connection epoch and must be resolved again + * after reconnecting. + */ +TrainlogStatus trainlog_mtp_ensure_folder( + unsigned int bus_number, + unsigned int device_number, + uint32_t storage_id, + uint32_t parent_folder_id, + const char *folder_name, + uint32_t *output_folder_id, + bool *output_created +); + /** * @brief Upload one local text file into an exact MTP folder. */ @@ -116,4 +133,11 @@ TrainlogStatus trainlog_mtp_delete_object( uint32_t item_id ); +TrainlogStatus trainlog_mtp_rename_object( + unsigned int bus_number, + unsigned int device_number, + uint32_t item_id, + const char *new_name +); + #endif diff --git a/tui/meson.build b/tui/meson.build index 53698fd..a7aa32e 100644 --- a/tui/meson.build +++ b/tui/meson.build @@ -198,6 +198,7 @@ trainlog_core_sources = files( 'src/training_context.c', 'src/usb.c', 'src/mtp.c', + 'src/generation_mtp.c', 'src/reps.c', 'src/session_generation.c', 'src/statistics.c', @@ -223,6 +224,7 @@ trainlog_core = static_library( m_dep, udev_dep, mtp_dep, + yyjson_dep, ], c_args: strict_c_args, ) @@ -237,6 +239,7 @@ trainlog_core_dep = declare_dependency( m_dep, udev_dep, mtp_dep, + yyjson_dep, ], ) @@ -552,6 +555,13 @@ trainlog_mtp_probe = executable( c_args: strict_c_args, ) +trainlog_generation_mtp_adapter = executable( + 'trainlog-generation-mtp-adapter', + 'tools/generation_mtp_adapter.c', + dependencies: trainlog_core_dep, + c_args: strict_c_args, +) + test_mtp = executable( 'test_mtp', 'tests/test_mtp.c', @@ -564,6 +574,21 @@ test( test_mtp, ) +test_generation_mtp = executable( + 'test_generation_mtp', + 'tests/test_generation_mtp.c', + dependencies: trainlog_core_dep, + c_args: strict_c_args, +) +test('generation_mtp', test_generation_mtp) + +generation_mtp_adapter_double = executable( + 'generation-mtp-adapter-double', + 'tests/generation_mtp_adapter_double.c', + dependencies: trainlog_core_dep, + c_args: strict_c_args, +) + trainlog_mtp_exchange_probe = executable( 'trainlog-mtp-exchange-probe', 'tools/mtp_exchange_probe.c', diff --git a/tui/src/generation_mtp.c b/tui/src/generation_mtp.c new file mode 100644 index 0000000..c7f48a0 --- /dev/null +++ b/tui/src/generation_mtp.c @@ -0,0 +1,301 @@ +#include "trainlog/generation_mtp.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +typedef struct Selection { + unsigned int bus; + unsigned int device; + uint32_t storage; + uint32_t root; +} Selection; + +static void diag(char *output, size_t size, const char *value) +{ + if (output != NULL && size > 0U) (void)snprintf(output, size, "%s", value); +} + +static bool safe_leaf(const char *name) +{ + return name != NULL && name[0] != '\0' && strcmp(name, ".") != 0 && + strcmp(name, "..") != 0 && strchr(name, '/') == NULL && strchr(name, '\\') == NULL; +} + +static TrainlogStatus children(const TrainlogGenerationMtpIo *io, const Selection *selection, + uint32_t parent, TrainlogMtpEntry *entries, size_t *count) +{ + return io->children(selection->bus, selection->device, selection->storage, + parent, entries, TRAINLOG_GENERATION_MTP_MAX_CHILDREN, count); +} + +static TrainlogStatus one_named(const TrainlogGenerationMtpIo *io, const Selection *selection, + uint32_t parent, const char *name, bool folder, TrainlogMtpEntry *result) +{ + TrainlogMtpEntry entries[TRAINLOG_GENERATION_MTP_MAX_CHILDREN]; + size_t count = 0U, index, matches = 0U; + TrainlogStatus status = children(io, selection, parent, entries, &count); + if (status != TRAINLOG_STATUS_OK) return status; + for (index = 0U; index < count; ++index) { + if (entries[index].folder == folder && strcmp(entries[index].name, name) == 0) { + *result = entries[index]; ++matches; + } + } + if (matches > 1U) return TRAINLOG_STATUS_CONFLICT; + return matches == 1U ? TRAINLOG_STATUS_OK : TRAINLOG_STATUS_NOT_FOUND; +} + +static bool peer_document_matches(const char *path, const char *expected) +{ + int fd = open(path, O_RDONLY | O_CLOEXEC); + char buffer[16385]; + ssize_t count; + yyjson_doc *document; + yyjson_val *root, *capabilities, *item; + size_t index, maximum; + bool manifest = false, acknowledgement = false, valid; + if (fd < 0) return false; + count = read(fd, buffer, sizeof(buffer)); + (void)close(fd); + if (count <= 0 || (size_t)count >= sizeof(buffer)) return false; + buffer[count] = '\0'; + document = yyjson_read(buffer, (size_t)count, YYJSON_READ_NOFLAG); + if (document == NULL) return false; + root = yyjson_doc_get_root(document); + capabilities = yyjson_obj_get(root, "capabilities"); + valid = yyjson_is_obj(root) && yyjson_obj_size(root) == 4U && + yyjson_equals_str(yyjson_obj_get(root, "format"), "trainlog-sync-peer") && + yyjson_is_uint(yyjson_obj_get(root, "version")) && + yyjson_get_uint(yyjson_obj_get(root, "version")) == 1U && + yyjson_equals_str(yyjson_obj_get(root, "peer_id"), expected) && + yyjson_is_arr(capabilities); + if (valid) { + yyjson_arr_foreach(capabilities, index, maximum, item) { + if (!yyjson_is_str(item)) { valid = false; break; } + if (yyjson_equals_str(item, "generation-manifest-v1")) manifest = true; + if (yyjson_equals_str(item, "generation-ack-v1")) acknowledgement = true; + } + } + yyjson_doc_free(document); + return valid && manifest && acknowledgement; +} + +static TrainlogStatus select_peer(const TrainlogGenerationMtpIo *io, const char *expected, + const char *local_root, Selection *output, char *diagnostic, size_t diagnostic_size) +{ + TrainlogUsbDevice devices[TRAINLOG_GENERATION_MTP_MAX_DEVICES]; + TrainlogMtpStorage storages[TRAINLOG_GENERATION_MTP_MAX_STORAGES]; + size_t device_count = 0U, storage_count, di, si, matches = 0U; + char peer_path[1024]; + TrainlogStatus status; + if (snprintf(peer_path, sizeof(peer_path), "%s/.peer-candidate", local_root) < 0 || + strlen(local_root) + strlen("/.peer-candidate") >= sizeof(peer_path)) { + return TRAINLOG_STATUS_INVALID_ARGUMENT; + } + status = io->devices(devices, TRAINLOG_GENERATION_MTP_MAX_DEVICES, &device_count); + if (status != TRAINLOG_STATUS_OK || device_count == 0U) { + diag(diagnostic, diagnostic_size, "no MTP device available"); return TRAINLOG_STATUS_NOT_FOUND; + } + for (di = 0U; di < device_count; ++di) { + status = io->storages(devices[di].bus_number, devices[di].device_number, + storages, TRAINLOG_GENERATION_MTP_MAX_STORAGES, &storage_count); + if (status != TRAINLOG_STATUS_OK) continue; + for (si = 0U; si < storage_count; ++si) { + Selection candidate = {devices[di].bus_number, devices[di].device_number, + storages[si].storage_id, 0U}; + TrainlogMtpEntry documents, root, peer; + if (one_named(io, &candidate, UINT32_MAX, "Documents", true, &documents) != TRAINLOG_STATUS_OK || + one_named(io, &candidate, documents.item_id, "Trainlog", true, &root) != TRAINLOG_STATUS_OK) continue; + candidate.root = root.item_id; + if (one_named(io, &candidate, candidate.root, "android-peer-v1.json", false, &peer) != TRAINLOG_STATUS_OK) continue; + (void)unlink(peer_path); + if (peer.size_bytes > 16384U || io->receive(candidate.bus, candidate.device, + peer.item_id, peer_path) != TRAINLOG_STATUS_OK || + !peer_document_matches(peer_path, expected)) continue; + *output = candidate; ++matches; + } + } + (void)unlink(peer_path); + if (matches == 0U) { diag(diagnostic, diagnostic_size, "expected Android peer not found"); return TRAINLOG_STATUS_NOT_FOUND; } + if (matches > 1U) { diag(diagnostic, diagnostic_size, "ambiguous compatible Android peers"); return TRAINLOG_STATUS_CONFLICT; } + return TRAINLOG_STATUS_OK; +} + +static TrainlogStatus mkdir_private(const char *path) +{ + if (mkdir(path, 0700) == 0 || errno == EEXIST) return TRAINLOG_STATUS_OK; + return TRAINLOG_STATUS_SYSTEM_ERROR; +} + +static TrainlogStatus pull_folder(const TrainlogGenerationMtpIo *io, const Selection *selection, + uint32_t parent, const char *local, unsigned int depth, uint64_t *total, size_t *objects) +{ + TrainlogMtpEntry entries[TRAINLOG_GENERATION_MTP_MAX_CHILDREN]; + size_t count = 0U, index; + TrainlogStatus status; + if (depth > TRAINLOG_GENERATION_MTP_MAX_DEPTH) return TRAINLOG_STATUS_INVALID_ARGUMENT; + status = mkdir_private(local); if (status != TRAINLOG_STATUS_OK) return status; + status = children(io, selection, parent, entries, &count); if (status != TRAINLOG_STATUS_OK) return status; + if (*objects > TRAINLOG_GENERATION_MTP_MAX_CHILDREN - count) return TRAINLOG_STATUS_INVALID_ARGUMENT; + *objects += count; + for (index = 0U; index < count; ++index) { + char path[1024], temporary[1060]; int written; + written = snprintf(path, sizeof(path), "%s/%s", local, entries[index].name); + if (!safe_leaf(entries[index].name) || written < 0 || (size_t)written >= sizeof(path)) return TRAINLOG_STATUS_INVALID_ARGUMENT; + if (entries[index].folder) { + status = pull_folder(io, selection, entries[index].item_id, path, depth + 1U, total, objects); + } else { + struct stat value; + if (entries[index].size_bytes > TRAINLOG_GENERATION_MTP_MAX_TOTAL_BYTES || + *total > TRAINLOG_GENERATION_MTP_MAX_TOTAL_BYTES - entries[index].size_bytes) return TRAINLOG_STATUS_INVALID_ARGUMENT; + *total += entries[index].size_bytes; + written = snprintf(temporary, sizeof(temporary), "%s.part.%ld", path, (long)getpid()); + if (written < 0 || (size_t)written >= sizeof(temporary)) return TRAINLOG_STATUS_INVALID_ARGUMENT; + (void)unlink(temporary); + status = io->receive(selection->bus, selection->device, entries[index].item_id, temporary); + if (status == TRAINLOG_STATUS_OK && (stat(temporary, &value) != 0 || value.st_size < 0 || + (uint64_t)value.st_size != entries[index].size_bytes || rename(temporary, path) != 0)) status = TRAINLOG_STATUS_SYSTEM_ERROR; + if (status != TRAINLOG_STATUS_OK) (void)unlink(temporary); + } + if (status != TRAINLOG_STATUS_OK) return status; + } + return TRAINLOG_STATUS_OK; +} + +static bool same_files(const char *left, const char *right) +{ + int a = open(left, O_RDONLY | O_CLOEXEC), b = open(right, O_RDONLY | O_CLOEXEC); + char ab[8192], bb[8192]; ssize_t ac = 0, bc; bool same = true; + if (a < 0 || b < 0) same = false; + while (same && (ac = read(a, ab, sizeof(ab))) > 0) { + bc = read(b, bb, (size_t)ac); if (bc != ac || memcmp(ab, bb, (size_t)ac) != 0) same = false; + } + if (same && (ac < 0 || read(b, bb, 1U) != 0)) same = false; + if (a >= 0) (void)close(a); if (b >= 0) (void)close(b); return same; +} + +static TrainlogStatus push_file(const TrainlogGenerationMtpIo *io, const Selection *selection, + uint32_t parent, const char *local, const char *name) +{ + TrainlogMtpEntry existing; TrainlogStatus status; + char comparison[1060], temporary_name[256]; uint32_t uploaded = 0U; + status = one_named(io, selection, parent, name, false, &existing); + if (status == TRAINLOG_STATUS_OK) { + (void)snprintf(comparison, sizeof(comparison), "%s.compare.%ld", local, (long)getpid()); + (void)unlink(comparison); + status = io->receive(selection->bus, selection->device, existing.item_id, comparison); + if (status != TRAINLOG_STATUS_OK) return status; + if (same_files(local, comparison)) { (void)unlink(comparison); return TRAINLOG_STATUS_OK; } + (void)unlink(comparison); + (void)snprintf(temporary_name, sizeof(temporary_name), ".trainlog-%ld-%s", (long)getpid(), name); + status = io->send(selection->bus, selection->device, selection->storage, parent, local, temporary_name, &uploaded); + if (status != TRAINLOG_STATUS_OK) return status; + (void)snprintf(comparison, sizeof(comparison), "%s.verify.%ld", local, (long)getpid()); + (void)unlink(comparison); + status = io->receive(selection->bus, selection->device, uploaded, comparison); + if (status != TRAINLOG_STATUS_OK || !same_files(local, comparison)) { + (void)unlink(comparison); (void)io->remove(selection->bus, selection->device, uploaded); + return status == TRAINLOG_STATUS_OK ? TRAINLOG_STATUS_CONFLICT : status; + } + (void)unlink(comparison); + status = io->remove(selection->bus, selection->device, existing.item_id); + if (status == TRAINLOG_STATUS_OK) status = io->rename(selection->bus, selection->device, uploaded, name); + return status; + } + if (status != TRAINLOG_STATUS_NOT_FOUND) return status; + status = io->send(selection->bus, selection->device, selection->storage, parent, local, name, &uploaded); + if (status != TRAINLOG_STATUS_OK) return status; + (void)snprintf(comparison, sizeof(comparison), "%s.verify.%ld", local, (long)getpid()); + (void)unlink(comparison); + status = io->receive(selection->bus, selection->device, uploaded, comparison); + if (status != TRAINLOG_STATUS_OK || !same_files(local, comparison)) { + (void)unlink(comparison); (void)io->remove(selection->bus, selection->device, uploaded); + return status == TRAINLOG_STATUS_OK ? TRAINLOG_STATUS_CONFLICT : status; + } + (void)unlink(comparison); + return TRAINLOG_STATUS_OK; +} + +static TrainlogStatus push_folder(const TrainlogGenerationMtpIo *io, const Selection *selection, + uint32_t parent, const char *local, unsigned int depth, bool manifests) +{ + DIR *directory; struct dirent *entry; TrainlogStatus status = TRAINLOG_STATUS_OK; + if (depth > TRAINLOG_GENERATION_MTP_MAX_DEPTH || (directory = opendir(local)) == NULL) return TRAINLOG_STATUS_SYSTEM_ERROR; + while ((entry = readdir(directory)) != NULL && status == TRAINLOG_STATUS_OK) { + char path[1024]; struct stat value; uint32_t child; bool created; + if (!safe_leaf(entry->d_name) || entry->d_name[0] == '.') continue; + if ((strcmp(entry->d_name, "manifest.json") == 0) != manifests) continue; + int written = snprintf(path, sizeof(path), "%s/%s", local, entry->d_name); + if (written < 0 || (size_t)written >= sizeof(path) || lstat(path, &value) != 0 || S_ISLNK(value.st_mode)) { status = TRAINLOG_STATUS_INVALID_ARGUMENT; break; } + if (S_ISDIR(value.st_mode)) { + if (manifests) continue; + status = io->ensure_folder(selection->bus, selection->device, selection->storage, parent, entry->d_name, &child, &created); + if (status == TRAINLOG_STATUS_OK) status = push_folder(io, selection, child, path, depth + 1U, false); + if (status == TRAINLOG_STATUS_OK) status = push_folder(io, selection, child, path, depth + 1U, true); + } else if (S_ISREG(value.st_mode)) status = push_file(io, selection, parent, path, entry->d_name); + } + (void)closedir(directory); return status; +} + +static TrainlogStatus validate_local_folder(const char *local, unsigned int depth, + uint64_t *total, size_t *objects) +{ + DIR *directory; + struct dirent *entry; + TrainlogStatus status = TRAINLOG_STATUS_OK; + if (depth > TRAINLOG_GENERATION_MTP_MAX_DEPTH || (directory = opendir(local)) == NULL) + return TRAINLOG_STATUS_INVALID_ARGUMENT; + while ((entry = readdir(directory)) != NULL && status == TRAINLOG_STATUS_OK) { + char path[1024]; struct stat value; int written; + if (strcmp(entry->d_name, ".") == 0 || strcmp(entry->d_name, "..") == 0) continue; + if (!safe_leaf(entry->d_name) || entry->d_name[0] == '.') { status = TRAINLOG_STATUS_INVALID_ARGUMENT; break; } + written = snprintf(path, sizeof(path), "%s/%s", local, entry->d_name); + if (written < 0 || (size_t)written >= sizeof(path) || lstat(path, &value) != 0 || + S_ISLNK(value.st_mode)) { status = TRAINLOG_STATUS_INVALID_ARGUMENT; break; } + if (*objects == TRAINLOG_GENERATION_MTP_MAX_CHILDREN) { status = TRAINLOG_STATUS_INVALID_ARGUMENT; break; } + ++*objects; + if (S_ISDIR(value.st_mode)) status = validate_local_folder(path, depth + 1U, total, objects); + else if (S_ISREG(value.st_mode)) { + if (value.st_size < 0 || (uint64_t)value.st_size > TRAINLOG_GENERATION_MTP_MAX_TOTAL_BYTES - *total) + status = TRAINLOG_STATUS_INVALID_ARGUMENT; + else *total += (uint64_t)value.st_size; + } else status = TRAINLOG_STATUS_INVALID_ARGUMENT; + } + (void)closedir(directory); + return status; +} + +TrainlogStatus trainlog_generation_mtp_pull(const TrainlogGenerationMtpIo *io, + const char *expected, const char *local, char *diagnostic, size_t diagnostic_size) +{ + Selection selection; uint64_t total = 0U; size_t objects = 0U; TrainlogStatus status; + if (io == NULL || expected == NULL || local == NULL) return TRAINLOG_STATUS_INVALID_ARGUMENT; + status = mkdir_private(local); if (status == TRAINLOG_STATUS_OK) status = select_peer(io, expected, local, &selection, diagnostic, diagnostic_size); + if (status == TRAINLOG_STATUS_OK) status = pull_folder(io, &selection, selection.root, local, 0U, &total, &objects); + return status; +} + +TrainlogStatus trainlog_generation_mtp_push(const TrainlogGenerationMtpIo *io, + const char *expected, const char *local, char *diagnostic, size_t diagnostic_size) +{ + Selection selection; TrainlogStatus status; uint64_t total = 0U; size_t objects = 0U; + if (io == NULL || expected == NULL || local == NULL) return TRAINLOG_STATUS_INVALID_ARGUMENT; + status = validate_local_folder(local, 0U, &total, &objects); + if (status == TRAINLOG_STATUS_OK) status = select_peer(io, expected, local, &selection, diagnostic, diagnostic_size); + if (status == TRAINLOG_STATUS_OK) status = push_folder(io, &selection, selection.root, local, 0U, false); + if (status == TRAINLOG_STATUS_OK) status = push_folder(io, &selection, selection.root, local, 0U, true); + return status; +} + +static const TrainlogGenerationMtpIo production = { + trainlog_usb_list_mtp_devices, trainlog_mtp_list_storages, trainlog_mtp_list_folder, + trainlog_mtp_ensure_folder, trainlog_mtp_receive_file, trainlog_mtp_send_text_file, + trainlog_mtp_delete_object, trainlog_mtp_rename_object +}; +const TrainlogGenerationMtpIo *trainlog_generation_mtp_production_io(void) { return &production; } diff --git a/tui/src/mtp.c b/tui/src/mtp.c index a6c897d..525f157 100644 --- a/tui/src/mtp.c +++ b/tui/src/mtp.c @@ -613,6 +613,58 @@ TrainlogStatus trainlog_mtp_send_text_file( return TRAINLOG_STATUS_OK; } +TrainlogStatus trainlog_mtp_ensure_folder( + unsigned int bus_number, + unsigned int device_number, + uint32_t storage_id, + uint32_t parent_folder_id, + const char *folder_name, + uint32_t *output_folder_id, + bool *output_created +) +{ + TrainlogMtpEntry entries[256]; + size_t count = 0U; + size_t index; + size_t matches = 0U; + LIBMTP_mtpdevice_t *device = NULL; + TrainlogStatus status; + uint32_t created; + char mutable_name[TRAINLOG_MTP_ENTRY_NAME_MAX + 1U]; + + if (folder_name == NULL || folder_name[0] == '\0' || + strlen(folder_name) > TRAINLOG_MTP_ENTRY_NAME_MAX || + output_folder_id == NULL || output_created == NULL) { + return TRAINLOG_STATUS_INVALID_ARGUMENT; + } + *output_folder_id = 0U; + *output_created = false; + status = trainlog_mtp_list_folder(bus_number, device_number, storage_id, + parent_folder_id, entries, 256U, &count); + if (status != TRAINLOG_STATUS_OK) return status; + for (index = 0U; index < count; ++index) { + if (entries[index].folder && strcmp(entries[index].name, folder_name) == 0) { + *output_folder_id = entries[index].item_id; + ++matches; + } + } + if (matches > 1U) return TRAINLOG_STATUS_CONFLICT; + if (matches == 1U) return TRAINLOG_STATUS_OK; + status = mtp_open_exact_device(bus_number, device_number, &device); + if (status != TRAINLOG_STATUS_OK) return status; + (void)snprintf(mutable_name, sizeof(mutable_name), "%s", folder_name); + created = LIBMTP_Create_Folder(device, mutable_name, parent_folder_id, storage_id); + if (created == 0U) { + LIBMTP_Clear_Errorstack(device); + LIBMTP_Release_Device(device); + return TRAINLOG_STATUS_SYSTEM_ERROR; + } + LIBMTP_Release_Device(device); + *output_folder_id = created; + *output_created = true; + return TRAINLOG_STATUS_OK; +} + TrainlogStatus trainlog_mtp_list_folder( unsigned int bus_number, unsigned int device_number, @@ -830,3 +882,35 @@ TrainlogStatus trainlog_mtp_delete_object( return TRAINLOG_STATUS_OK; } + +TrainlogStatus trainlog_mtp_rename_object( + unsigned int bus_number, + unsigned int device_number, + uint32_t item_id, + const char *new_name +) +{ + LIBMTP_mtpdevice_t *device = NULL; + LIBMTP_file_t *metadata; + TrainlogStatus status; + int rc; + if (item_id == 0U || new_name == NULL || new_name[0] == '\0' || + strlen(new_name) > TRAINLOG_MTP_ENTRY_NAME_MAX) { + return TRAINLOG_STATUS_INVALID_ARGUMENT; + } + status = mtp_open_exact_device(bus_number, device_number, &device); + if (status != TRAINLOG_STATUS_OK) return status; + metadata = LIBMTP_Get_Filemetadata(device, item_id); + if (metadata == NULL) { + LIBMTP_Clear_Errorstack(device); LIBMTP_Release_Device(device); + return TRAINLOG_STATUS_NOT_FOUND; + } + rc = LIBMTP_Set_File_Name(device, metadata, new_name); + LIBMTP_destroy_file_t(metadata); + if (rc != 0) { + LIBMTP_Clear_Errorstack(device); LIBMTP_Release_Device(device); + return TRAINLOG_STATUS_SYSTEM_ERROR; + } + LIBMTP_Release_Device(device); + return TRAINLOG_STATUS_OK; +} diff --git a/tui/tests/generation_mtp_adapter_double.c b/tui/tests/generation_mtp_adapter_double.c new file mode 100644 index 0000000..97d887c --- /dev/null +++ b/tui/tests/generation_mtp_adapter_double.c @@ -0,0 +1,24 @@ +#include +#include +#include +#include +#include +#include +#include "trainlog/generation_mtp.h" + +typedef struct Node { uint32_t id,parent; bool folder; char path[1024],name[256]; uint64_t size; } Node; +static Node nodes[2048]; static size_t node_count; static const char *remote; +static uint32_t scan(const char *path,uint32_t parent){DIR *d;struct dirent *e;uint32_t self=(uint32_t)(node_count+1U);Node *n=&nodes[node_count++];struct stat st;lstat(path,&st);n->id=self;n->parent=parent;n->folder=S_ISDIR(st.st_mode);n->size=(uint64_t)st.st_size;snprintf(n->path,sizeof(n->path),"%s",path);const char *slash=strrchr(path,'/');snprintf(n->name,sizeof(n->name),"%s",slash?slash+1:path);if(n->folder&&(d=opendir(path))!=NULL){while((e=readdir(d)))if(strcmp(e->d_name,".")&&strcmp(e->d_name,"..")){char child[1024];snprintf(child,sizeof(child),"%s/%s",path,e->d_name);scan(child,self);}closedir(d);}return self;} +static void rebuild(void){node_count=0U;scan(remote,UINT32_MAX);} +static Node *node(uint32_t id){size_t i;for(i=0;ibus_number=1;o->device_number=1;*n=1;return TRAINLOG_STATUS_OK;} +static TrainlogStatus storages(unsigned int b,unsigned int d,TrainlogMtpStorage *o,size_t c,size_t *n){(void)b;(void)d;if(c<1U)return TRAINLOG_STATUS_INVALID_ARGUMENT;memset(o,0,sizeof(*o));o->storage_id=1;*n=1;return TRAINLOG_STATUS_OK;} +static TrainlogStatus children(unsigned int b,unsigned int d,uint32_t s,uint32_t p,TrainlogMtpEntry *o,size_t c,size_t *n){size_t i,k=0;(void)b;(void)d;(void)s;rebuild();if(p==UINT32_MAX)p=nodes[0].id;for(i=0;i=c)return TRAINLOG_STATUS_INVALID_ARGUMENT;memset(&o[k],0,sizeof(o[k]));o[k].item_id=nodes[i].id;o[k].parent_id=p;o[k].storage_id=1;o[k].folder=nodes[i].folder;o[k].size_bytes=nodes[i].size;snprintf(o[k].name,sizeof(o[k].name),"%s",nodes[i].name);k++;}*n=k;return TRAINLOG_STATUS_OK;} +static TrainlogStatus ensure(unsigned int b,unsigned int d,uint32_t s,uint32_t p,const char *name,uint32_t *id,bool *created){Node *parent;char path[1024];(void)b;(void)d;(void)s;rebuild();parent=node(p);if(!parent)return TRAINLOG_STATUS_NOT_FOUND;snprintf(path,sizeof(path),"%s/%s",parent->path,name);if(mkdir(path,0700)==0)*created=true;else if(access(path,F_OK)==0)*created=false;else return TRAINLOG_STATUS_SYSTEM_ERROR;rebuild();size_t i;for(i=0;i0)if(fwrite(buf,1,n,out)!=n)return -1;return fclose(in)|fclose(out);} +static TrainlogStatus receive(unsigned int b,unsigned int d,uint32_t id,const char *path){Node *n;(void)b;(void)d;rebuild();n=node(id);return n&©(n->path,path)==0?TRAINLOG_STATUS_OK:TRAINLOG_STATUS_SYSTEM_ERROR;} +static TrainlogStatus send_file(unsigned int b,unsigned int d,uint32_t s,uint32_t p,const char *local,const char *name,uint32_t *id){Node *parent;char path[1024];(void)b;(void)d;(void)s;rebuild();parent=node(p);if(!parent)return TRAINLOG_STATUS_NOT_FOUND;snprintf(path,sizeof(path),"%s/%s",parent->path,name);if(copy(local,path)!=0)return TRAINLOG_STATUS_SYSTEM_ERROR;rebuild();size_t i;for(i=0;ipath)==0?TRAINLOG_STATUS_OK:TRAINLOG_STATUS_SYSTEM_ERROR;} +static TrainlogStatus rename_object(unsigned int b,unsigned int d,uint32_t id,const char *name){Node *n;char path[1024],*slash;(void)b;(void)d;rebuild();n=node(id);if(!n)return TRAINLOG_STATUS_NOT_FOUND;snprintf(path,sizeof(path),"%s",n->path);slash=strrchr(path,'/');if(!slash)return TRAINLOG_STATUS_SYSTEM_ERROR;snprintf(slash+1,(size_t)(path+sizeof(path)-(slash+1)),"%s",name);return rename(n->path,path)==0?TRAINLOG_STATUS_OK:TRAINLOG_STATUS_SYSTEM_ERROR;} +static const TrainlogGenerationMtpIo io={devices,storages,children,ensure,receive,send_file,remove_object,rename_object}; +int main(int argc,char **argv){char diag[1024]={0};TrainlogStatus status;remote=getenv("TRAINLOG_MTP_DOUBLE_ROOT");if(argc!=4||!remote)return 64;if(strcmp(argv[1],"pull")==0)status=trainlog_generation_mtp_pull(&io,argv[2],argv[3],diag,sizeof(diag));else if(strcmp(argv[1],"push")==0)status=trainlog_generation_mtp_push(&io,argv[2],argv[3],diag,sizeof(diag));else return 64;if(status!=TRAINLOG_STATUS_OK){fprintf(stderr,"double failed %d %s\n",status,diag);return 2;}return 0;} diff --git a/tui/tests/test_generation_mtp.c b/tui/tests/test_generation_mtp.c new file mode 100644 index 0000000..c75cd53 --- /dev/null +++ b/tui/tests/test_generation_mtp.c @@ -0,0 +1,26 @@ +#include +#include +#include +#include +#include +#include "trainlog/generation_mtp.h" + +#define CHECK(x) do { if (!(x)) { fprintf(stderr,"CHECK %s:%d: %s\n",__FILE__,__LINE__,#x); return 1; } } while (0) +typedef struct Object { uint32_t id,parent; bool folder; char name[256]; char data[4096]; size_t size; } Object; +static Object objects[64]; static size_t object_count; static unsigned int device_count=1U; static char send_order[1024]; +static void add(uint32_t id,uint32_t parent,bool folder,const char *name,const char *data){Object *o=&objects[object_count++];memset(o,0,sizeof(*o));o->id=id;o->parent=parent;o->folder=folder;snprintf(o->name,sizeof(o->name),"%s",name);if(data){o->size=strlen(data);memcpy(o->data,data,o->size);}} +static void reset(void){object_count=0U;device_count=1U;send_order[0]='\0';add(1,UINT32_MAX,true,"Documents",NULL);add(2,1,true,"Trainlog",NULL);add(3,2,false,"android-peer-v1.json","{\"format\":\"trainlog-sync-peer\",\"version\":1,\"peer_id\":\"peer_11111111-1111-4111-8111-111111111111\",\"capabilities\":[\"generation-manifest-v1\",\"generation-ack-v1\"]}");} +static TrainlogStatus devices(TrainlogUsbDevice *out,size_t cap,size_t *count){size_t i;if(capstorage_id=dev;*count=1U;return TRAINLOG_STATUS_OK;} +static TrainlogStatus children(unsigned int bus,unsigned int dev,uint32_t storage,uint32_t parent,TrainlogMtpEntry *out,size_t cap,size_t *count){size_t i,n=0;(void)bus;(void)storage;for(i=0;i=cap)return TRAINLOG_STATUS_INVALID_ARGUMENT;memset(&out[n],0,sizeof(out[n]));out[n].item_id=objects[i].id;out[n].parent_id=parent;out[n].storage_id=dev;out[n].folder=objects[i].folder;out[n].size_bytes=objects[i].size;snprintf(out[n].name,sizeof(out[n].name),"%s",objects[i].name);n++;}*count=n;return TRAINLOG_STATUS_OK;} +static TrainlogStatus ensure_folder(unsigned int b,unsigned int d,uint32_t s,uint32_t p,const char *name,uint32_t *id,bool *created){size_t i;(void)b;(void)d;(void)s;for(i=0;isize;if(fwrite(o->data,1,bytes,f)!=bytes){fclose(f);return TRAINLOG_STATUS_SYSTEM_ERROR;}return fclose(f)==0?TRAINLOG_STATUS_OK:TRAINLOG_STATUS_SYSTEM_ERROR;} +static TrainlogStatus send_file(unsigned int b,unsigned int d,uint32_t s,uint32_t p,const char *path,const char *name,uint32_t *id){FILE *f;Object *o;long size;(void)b;(void)d;(void)s;f=fopen(path,"rb");if(!f)return TRAINLOG_STATUS_SYSTEM_ERROR;fseek(f,0,SEEK_END);size=ftell(f);rewind(f);if(size<0||size>(long)sizeof(objects[0].data)){fclose(f);return TRAINLOG_STATUS_INVALID_ARGUMENT;}*id=(uint32_t)(100U+object_count);add(*id,p,false,name,NULL);o=&objects[object_count-1];o->size=(size_t)size;if(fread(o->data,1,o->size,f)!=o->size){fclose(f);return TRAINLOG_STATUS_SYSTEM_ERROR;}fclose(f);strncat(send_order,name,sizeof(send_order)-strlen(send_order)-2U);strncat(send_order,"\n",sizeof(send_order)-strlen(send_order)-1U);return TRAINLOG_STATUS_OK;} +static TrainlogStatus remove_object(unsigned int b,unsigned int d,uint32_t id){Object *o=find(id);(void)b;(void)d;if(!o)return TRAINLOG_STATUS_NOT_FOUND;o->name[0]='\0';return TRAINLOG_STATUS_OK;} +static TrainlogStatus rename_object(unsigned int b,unsigned int d,uint32_t id,const char *name){Object *o=find(id);(void)b;(void)d;if(!o)return TRAINLOG_STATUS_NOT_FOUND;snprintf(o->name,sizeof(o->name),"%s",name);return TRAINLOG_STATUS_OK;} +static const TrainlogGenerationMtpIo io={devices,storages,children,ensure_folder,receive,send_file,remove_object,rename_object}; +static int write_file(const char *path,const char *value){FILE *f=fopen(path,"wb");CHECK(f!=NULL);CHECK(fwrite(value,1,strlen(value),f)==strlen(value));CHECK(fclose(f)==0);return 0;} +static int test_pull_and_manifest_last(void){char root[]="/tmp/trainlog-generation-mtp-XXXXXX",path[512],diag[256]={0};char *manifest;reset();CHECK(mkdtemp(root)!=NULL);add(4,2,true,"android-objects",NULL);add(5,4,true,"generations",NULL);add(6,5,true,"gen_11111111-1111-4111-8111-111111111111",NULL);add(7,6,false,"payload.json","payload");add(8,6,false,"manifest.json","manifest");CHECK(trainlog_generation_mtp_pull(&io,"peer_11111111-1111-4111-8111-111111111111",root,diag,sizeof(diag))==TRAINLOG_STATUS_OK);snprintf(path,sizeof(path),"%s/android-objects/generations/gen_11111111-1111-4111-8111-111111111111/payload.json",root);CHECK(access(path,R_OK)==0);snprintf(path,sizeof(path),"%s/out",root);CHECK(mkdir(path,0700)==0);char gen[512];snprintf(gen,sizeof(gen),"%s/generation",path);CHECK(mkdir(gen,0700)==0);char artifact[512];snprintf(artifact,sizeof(artifact),"%s/artifact.json",gen);CHECK(write_file(artifact,"a")==0);char marker[512];snprintf(marker,sizeof(marker),"%s/manifest.json",gen);CHECK(write_file(marker,"m")==0);CHECK(trainlog_generation_mtp_push(&io,"peer_11111111-1111-4111-8111-111111111111",root,diag,sizeof(diag))==TRAINLOG_STATUS_OK);manifest=strstr(send_order,"manifest.json\n");CHECK(manifest!=NULL);CHECK(strstr(send_order,"artifact.json\n")data,sizeof(peer->data),"not-json containing peer_11111111-1111-4111-8111-111111111111 generation-manifest-v1 generation-ack-v1");peer->size=strlen(peer->data);CHECK(trainlog_generation_mtp_pull(&io,"peer_11111111-1111-4111-8111-111111111111",root,diag,sizeof(diag))==TRAINLOG_STATUS_NOT_FOUND);reset();add(9,2,false,"bad.bin","abc");find(9)->size=99U;status=trainlog_generation_mtp_pull(&io,"peer_11111111-1111-4111-8111-111111111111",root,diag,sizeof(diag));CHECK(status!=TRAINLOG_STATUS_OK);snprintf(path,sizeof(path),"%s/bad.bin",root);CHECK(access(path,F_OK)!=0);return 0;} +int main(void){CHECK(test_pull_and_manifest_last()==0);CHECK(test_ambiguity_and_truncation()==0);return 0;} diff --git a/tui/tools/generation_mtp_adapter.c b/tui/tools/generation_mtp_adapter.c new file mode 100644 index 0000000..6c381e7 --- /dev/null +++ b/tui/tools/generation_mtp_adapter.c @@ -0,0 +1,23 @@ +#include +#include +#include "trainlog/generation_mtp.h" + +int main(int argc, char **argv) +{ + char diagnostic[1024] = {0}; TrainlogStatus status; + if (argc == 2 && strcmp(argv[1], "--help") == 0) { + (void)puts("usage: trainlog-generation-mtp-adapter pull|push EXPECTED_PEER LOCAL_ROOT"); + return 0; + } + if (argc != 4) return 64; + if (strcmp(argv[1], "pull") == 0) status = trainlog_generation_mtp_pull( + trainlog_generation_mtp_production_io(), argv[2], argv[3], diagnostic, sizeof(diagnostic)); + else if (strcmp(argv[1], "push") == 0) status = trainlog_generation_mtp_push( + trainlog_generation_mtp_production_io(), argv[2], argv[3], diagnostic, sizeof(diagnostic)); + else return 64; + if (status != TRAINLOG_STATUS_OK) { + (void)fprintf(stderr, "MTP adapter failed status=%d diagnostic=%s\n", (int)status, diagnostic); + return 2; + } + return 0; +}