Compare commits

...

5 commits

31 changed files with 5051 additions and 1444 deletions

19
.clang-format Normal file
View file

@ -0,0 +1,19 @@
---
BasedOnStyle: LLVM
Language: Cpp
Standard: c++17
ColumnLimit: 100
IndentWidth: 4
InsertBraces: true
ContinuationIndentWidth: 4
UseTab: Never
BreakBeforeBraces: Attach
AllowShortBlocksOnASingleLine: Never
AllowShortCaseLabelsOnASingleLine: false
AllowShortEnumsOnASingleLine: false
AllowShortFunctionsOnASingleLine: None
AllowShortIfStatementsOnASingleLine: Never
AllowShortLoopsOnASingleLine: false
BinPackArguments: false
BinPackParameters: false
SortIncludes: Never

View file

@ -287,6 +287,12 @@ Exact UI strings, user data, identifiers, commands, paths, protocol fields,
version/status markers, citations, and historical evidence are not translated
merely to satisfy the prose-language rule.
Production code, tests, fixtures expressed as source, and repository tooling
must remain readable by a human reviewer. Do not compress functions, control
flow, declarations, assertions, or cleanup onto one line to reduce line or
token count. Grouped missions may and should use small comprehensible commits.
Perform an explicit readability review before the final commit.
Canonical documents:
- `README.md`;

View file

@ -2,6 +2,12 @@
## Unreleased — 0.1.2
- Normalized the recent synchronization, generation-MTP, Android backup,
Web Dashboard serialization, tests, I/O doubles and packaging code for human
readability. Added scoped C formatting rules and review guidance without
changing synchronization protocols, assertions, deployment state or runtime
behavior.
- 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

View file

@ -15,10 +15,15 @@ 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)
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) {
@ -34,26 +39,58 @@ internal class AndroidBackupService(private val context: Context) {
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) }
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<String>().sorted()))
else -> error("Unsupported preference type")
}
}
})
} })
}.toString()
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<String>()
.sorted()
),
)
else -> error("Unsupported preference type")
}
}
},
)
}
},
)
}
.toString()
fun createPrivate(repository: TrainlogRepository): Result<File> = runCatching {
val directory = File(context.filesDir, "backups").apply { mkdirs() }
@ -66,85 +103,197 @@ internal class AndroidBackupService(private val context: Context) {
}
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)
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}")
AndroidBackupResult.Error(
"Export failed; private backup retained at ${archive.name}: ${error.message}"
)
}
}
private fun writeArchive(repository: TrainlogRepository, destination: OutputStream): AndroidBackupResult {
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")
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())
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)) }))
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()
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() }
} catch (error: Exception) {
AndroidBackupResult.Error(error.message ?: "Backup failed.")
} finally {
stage.deleteRecursively()
}
}
fun verify(input: InputStream): Result<VerifiedAndroidBackup> {
val stage = File(context.cacheDir, "restore-${java.util.UUID.randomUUID()}")
if (!stage.mkdir()) return Result.failure(IllegalStateException("Cannot create restore staging."))
if (!stage.mkdir())
return Result.failure(IllegalStateException("Cannot create restore staging."))
return runCatching {
var total = 0L; val names = mutableSetOf<String>()
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) }
var total = 0L
val names = mutableSetOf<String>()
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<String>()
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"),
)
}
}
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<String>()
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() }
.onFailure { stage.deleteRecursively() }
}
fun restore(repository: TrainlogRepository, verified: VerifiedAndroidBackup, databaseName: String = "trainlog-android.db"): AndroidBackupResult {
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")
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()
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()
validateDatabase(destination)
restorePreferences(verified.preferences)
previous.delete()
previousPreferences.delete()
verified.directory.deleteRecursively()
AndroidBackupResult.Success(destination.length())
} catch (error: Exception) {
destination.delete()
@ -157,20 +306,40 @@ internal class AndroidBackupService(private val context: Context) {
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" })
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() }
} 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()) }
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())
}
}
}

View file

@ -9,6 +9,7 @@ import org.json.JSONObject
internal sealed interface ForegroundGenerationResult {
data class Completed(val runId: String) : ForegroundGenerationResult
data class Error(val message: String) : ForegroundGenerationResult
}
@ -19,43 +20,86 @@ internal class SyncGenerationForegroundCoordinator(private val repository: Train
private fun publish(path: File, value: String) {
val temporary = File(path.parentFile, ".${path.name}.tmp-${UUID.randomUUID()}")
FileOutputStream(temporary).use { stream ->
stream.write(value.toByteArray()); stream.flush(); stream.fd.sync()
stream.write(value.toByteArray())
stream.flush()
stream.fd.sync()
}
if (!temporary.renameTo(path)) throw SyncGenerationException("cannot publish ${path.name}")
}
private fun await(path: File, deadline: Long): File {
while (!path.isFile) {
if (System.nanoTime() >= deadline) throw SyncGenerationException("timeout waiting for ${path.name}")
if (System.nanoTime() >= deadline)
throw SyncGenerationException("timeout waiting for ${path.name}")
Thread.sleep(50)
}
return path
}
fun run(directory: File, timeout: Duration = Duration.ofMinutes(5), afterPublication: (() -> Unit)? = null): ForegroundGenerationResult = try {
if (!directory.isDirectory) throw SyncGenerationException("exchange directory is unavailable")
val peer = service.peerId()
publish(File(directory, "android-peer-v1.json"), JSONObject()
.put("format", "trainlog-sync-peer").put("version", 1).put("peer_id", peer)
.put("capabilities", JSONArray(listOf("generation-manifest-v1", "generation-ack-v1",
"causal-delete-v1", "mobile-history-v4", "execution-draft-v1"))).toString())
val deadline = System.nanoTime() + timeout.toNanos()
val request = JSONObject(await(File(directory, "request-v1.json"), deadline).readText())
if (request.getString("android_peer_id") != peer) throw SyncGenerationException("request targets another Android peer")
val captured = service.capture(directory, request.getString("desktop_peer_id"), request.getString("run_id"))
val published = service.publish(captured, File(directory, "android-objects"))
publish(File(directory, "android-generation-v1.json"), JSONObject()
.put("format", "trainlog-sync-generation-reference").put("version", 1)
.put("run_id", captured.runId).put("generation_id", captured.generationId)
.put("manifest_sha256", captured.manifestSha256)
.put("relative_path", published.relativeTo(directory).path).toString())
afterPublication?.invoke()
service.acceptAcknowledgement(await(File(directory, "desktop-consumption-ack-v1.json"), deadline).readBytes())
val reference = JSONObject(await(File(directory, "desktop-generation-v1.json"), deadline).readText())
val acknowledgement = service.consume(File(directory, reference.getString("relative_path")))
publish(File(directory, "android-consumption-ack-v1.json"), acknowledgement)
ForegroundGenerationResult.Completed(captured.runId)
} catch (error: Exception) {
ForegroundGenerationResult.Error(error.message ?: "generation synchronization failed")
}
fun run(
directory: File,
timeout: Duration = Duration.ofMinutes(5),
afterPublication: (() -> Unit)? = null,
): ForegroundGenerationResult =
try {
if (!directory.isDirectory)
throw SyncGenerationException("exchange directory is unavailable")
val peer = service.peerId()
publish(
File(directory, "android-peer-v1.json"),
JSONObject()
.put("format", "trainlog-sync-peer")
.put("version", 1)
.put("peer_id", peer)
.put(
"capabilities",
JSONArray(
listOf(
"generation-manifest-v1",
"generation-ack-v1",
"causal-delete-v1",
"mobile-history-v4",
"execution-draft-v1",
)
),
)
.toString(),
)
val deadline = System.nanoTime() + timeout.toNanos()
val request = JSONObject(await(File(directory, "request-v1.json"), deadline).readText())
if (request.getString("android_peer_id") != peer)
throw SyncGenerationException("request targets another Android peer")
val captured =
service.capture(
directory,
request.getString("desktop_peer_id"),
request.getString("run_id"),
)
val published = service.publish(captured, File(directory, "android-objects"))
publish(
File(directory, "android-generation-v1.json"),
JSONObject()
.put("format", "trainlog-sync-generation-reference")
.put("version", 1)
.put("run_id", captured.runId)
.put("generation_id", captured.generationId)
.put("manifest_sha256", captured.manifestSha256)
.put("relative_path", published.relativeTo(directory).path)
.toString(),
)
afterPublication?.invoke()
service.acceptAcknowledgement(
await(File(directory, "desktop-consumption-ack-v1.json"), deadline).readBytes()
)
val reference =
JSONObject(
await(File(directory, "desktop-generation-v1.json"), deadline).readText()
)
val acknowledgement =
service.consume(File(directory, reference.getString("relative_path")))
publish(File(directory, "android-consumption-ack-v1.json"), acknowledgement)
ForegroundGenerationResult.Completed(captured.runId)
} catch (error: Exception) {
ForegroundGenerationResult.Error(error.message ?: "generation synchronization failed")
}
}

View file

@ -5,8 +5,8 @@ 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.SessionExerciseDraft
import com.labfytools.trainlog.model.SessionSetDraft
import com.labfytools.trainlog.model.TrackingMode
import java.io.ByteArrayInputStream
@ -24,20 +24,43 @@ import org.robolectric.annotation.Config
class AndroidBackupServiceTest {
private val context: Context = ApplicationProvider.getApplicationContext()
@Test fun completeBackupRestoresDraftPeerLedgersAndPreferences() {
@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 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()
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()
@ -47,15 +70,26 @@ class AndroidBackupServiceTest {
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) }
assertEquals(
"en",
context
.getSharedPreferences("trainlog_presentation_settings", Context.MODE_PRIVATE)
.getString("language", null),
)
} finally {
repository.close()
context.deleteDatabase(name)
}
}
@Test fun corruptAndTraversalContainersAreRejectedBeforeRestore() {
@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()
zip.putNextEntry(java.util.zip.ZipEntry("../database.sqlite"))
zip.write(byteArrayOf(1))
zip.closeEntry()
}
assertTrue(service.verify(ByteArrayInputStream(output.toByteArray())).isFailure)
}

View file

@ -2,8 +2,8 @@ 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.io.FileInputStream
import java.util.UUID
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
@ -13,19 +13,37 @@ import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class) @Config(sdk=[35])
@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)
@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)}
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)
}
}
}

View file

@ -36,7 +36,9 @@ class SyncDeploymentConversationTest {
private fun publish(file: File, value: String) {
val temporary = File(file.parentFile, ".${file.name}.tmp-${UUID.randomUUID()}")
temporary.outputStream().use { output ->
output.write(value.toByteArray()); output.flush(); output.fd.sync()
output.write(value.toByteArray())
output.flush()
output.fd.sync()
}
check(temporary.renameTo(file)) { "cannot publish ${file.name}" }
}
@ -44,42 +46,83 @@ class SyncDeploymentConversationTest {
@Test
fun foregroundAndroidServiceCompletesCorrelatedDesktopConversation() {
val suppliedTransport = System.getenv("TRAINLOG_SYNC_TRANSPORT_ROOT")
val transport = suppliedTransport?.let(::File) ?: kotlin.io.path.createTempDirectory("deployment-conversation-").toFile()
val repositoryRoot = generateSequence(File(checkNotNull(System.getProperty("user.dir")))) { it.parentFile }
.first { File(it, "tools/sync_peer_worker.py").isFile }
val transport =
suppliedTransport?.let(::File)
?: kotlin.io.path.createTempDirectory("deployment-conversation-").toFile()
val repositoryRoot =
generateSequence(File(checkNotNull(System.getProperty("user.dir")))) { it.parentFile }
.first { File(it, "tools/sync_peer_worker.py").isFile }
val databaseName = "deployment-peer-${UUID.randomUUID()}.db"
var repository = TrainlogRepository(context, databaseName)
var localWorker: Process? = null
try {
transport.mkdirs()
val exercise = (repository.createExercise(NewExerciseProfile(
"Deployment conversation", RecordingMode.SETS, TrackingMode.REPS, 0,
)) as CreateExerciseResult.Created).exercise
val occurrence = SessionExerciseDraft(exercise = exercise,
sets = listOf(SessionSetDraft(reps = 7, weightKg = 22.5)))
assertEquals(ActiveDraftMutationResult.Saved,
repository.saveActiveSessionDraft(ActiveSessionDraft(exercises = listOf(occurrence))))
val exercise =
(repository.createExercise(
NewExerciseProfile(
"Deployment conversation",
RecordingMode.SETS,
TrackingMode.REPS,
0,
)
) as CreateExerciseResult.Created)
.exercise
val occurrence =
SessionExerciseDraft(
exercise = exercise,
sets = listOf(SessionSetDraft(reps = 7, weightKg = 22.5)),
)
assertEquals(
ActiveDraftMutationResult.Saved,
repository.saveActiveSessionDraft(
ActiveSessionDraft(exercises = listOf(occurrence))
),
)
val coordinator = SyncGenerationForegroundCoordinator(repository)
val release = System.getenv("TRAINLOG_SYNC_TEST_RELEASE_FILE")
val executor = java.util.concurrent.Executors.newSingleThreadExecutor()
val result = executor.submit<ForegroundGenerationResult> {
coordinator.run(transport, Duration.ofSeconds(30)) {
release?.let { awaitFile(File(it)) }
val result =
executor.submit<ForegroundGenerationResult> {
coordinator.run(transport, Duration.ofSeconds(30)) {
release?.let { awaitFile(File(it)) }
}
}
}
val peer = JSONObject(awaitFile(File(transport, "android-peer-v1.json")).readText())
if (suppliedTransport == null) {
val desktop = File(transport, "desktop.sqlite")
val fixture = ProcessBuilder(File(repositoryRoot, "build/tui/sync-generation-fixture").absolutePath,
desktop.absolutePath).directory(repositoryRoot).start()
val fixture =
ProcessBuilder(
File(repositoryRoot, "build/tui/sync-generation-fixture").absolutePath,
desktop.absolutePath,
)
.directory(repositoryRoot)
.start()
assertEquals(0, fixture.waitFor())
localWorker = ProcessBuilder("python3", File(repositoryRoot, "tools/sync_peer_worker.py").absolutePath,
"--database", desktop.absolutePath, "--transport-root", transport.absolutePath,
"--owned-root", File(transport, "owned").absolutePath, "--run-id", "sy_${UUID.randomUUID()}",
"--expected-peer", peer.getString("peer_id"), "--timeout", "30").directory(repositoryRoot)
.redirectErrorStream(true).start()
localWorker =
ProcessBuilder(
"python3",
File(repositoryRoot, "tools/sync_peer_worker.py").absolutePath,
"--database",
desktop.absolutePath,
"--transport-root",
transport.absolutePath,
"--owned-root",
File(transport, "owned").absolutePath,
"--run-id",
"sy_${UUID.randomUUID()}",
"--expected-peer",
peer.getString("peer_id"),
"--timeout",
"30",
)
.directory(repositoryRoot)
.redirectErrorStream(true)
.start()
}
assertTrue(result.get(35, java.util.concurrent.TimeUnit.SECONDS) is ForegroundGenerationResult.Completed)
assertTrue(
result.get(35, java.util.concurrent.TimeUnit.SECONDS)
is ForegroundGenerationResult.Completed
)
executor.shutdownNow()
repository.close()
repository = TrainlogRepository(context, databaseName)

View file

@ -9,6 +9,10 @@ import com.labfytools.trainlog.model.SessionDraft
import com.labfytools.trainlog.model.SessionExerciseDraft
import com.labfytools.trainlog.model.SessionSetDraft
import com.labfytools.trainlog.model.TrackingMode
import java.nio.file.Files
import java.util.UUID
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import org.json.JSONObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
@ -16,10 +20,6 @@ import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import java.nio.file.Files
import java.util.UUID
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [35])
@ -28,157 +28,493 @@ class SyncGenerationServiceTest {
@Test
fun v19MigrationPreservesDataAndInventsNoGenerationState() {
val name="generation-migration-${UUID.randomUUID()}.db"
TrainlogRepository(context,name).also{
assertTrue(it.createExercise(NewExerciseProfile("Migration sentinel",RecordingMode.SETS,TrackingMode.REPS,0)) is CreateExerciseResult.Created)
val name = "generation-migration-${UUID.randomUUID()}.db"
TrainlogRepository(context, name).also {
assertTrue(
it.createExercise(
NewExerciseProfile(
"Migration sentinel",
RecordingMode.SETS,
TrackingMode.REPS,
0,
)
) is CreateExerciseResult.Created
)
it.close()
}
val path=context.getDatabasePath(name)
SQLiteDatabase.openDatabase(path.absolutePath,null,SQLiteDatabase.OPEN_READWRITE).use{db->
listOf("sync_causal_publications","sync_acknowledgements","sync_consumed_generations","sync_generation_artifacts","sync_generations","sync_peer_identity").forEach{db.execSQL("DROP TABLE $it")}
val path = context.getDatabasePath(name)
SQLiteDatabase.openDatabase(path.absolutePath, null, SQLiteDatabase.OPEN_READWRITE).use { db
->
listOf(
"sync_causal_publications",
"sync_acknowledgements",
"sync_consumed_generations",
"sync_generation_artifacts",
"sync_generations",
"sync_peer_identity",
)
.forEach { db.execSQL("DROP TABLE $it") }
db.execSQL("PRAGMA user_version=19")
}
val migrated=TrainlogRepository(context,name).also{it.listExercises()}
try{
SQLiteDatabase.openDatabase(path.absolutePath,null,SQLiteDatabase.OPEN_READONLY).use{db->
db.rawQuery("PRAGMA user_version",null).use{assertTrue(it.moveToFirst());assertEquals(20,it.getInt(0))}
db.rawQuery("SELECT COUNT(*) FROM exercises",null).use{assertTrue(it.moveToFirst());assertTrue(it.getInt(0)>0)}
listOf("sync_generations","sync_consumed_generations","sync_acknowledgements","sync_causal_publications").forEach{table->db.rawQuery("SELECT COUNT(*) FROM $table",null).use{assertTrue(it.moveToFirst());assertEquals(0,it.getInt(0))}}
}
}finally{migrated.close();context.deleteDatabase(name)}
val migrated = TrainlogRepository(context, name).also { it.listExercises() }
try {
SQLiteDatabase.openDatabase(path.absolutePath, null, SQLiteDatabase.OPEN_READONLY)
.use { db ->
db.rawQuery("PRAGMA user_version", null).use {
assertTrue(it.moveToFirst())
assertEquals(20, it.getInt(0))
}
db.rawQuery("SELECT COUNT(*) FROM exercises", null).use {
assertTrue(it.moveToFirst())
assertTrue(it.getInt(0) > 0)
}
listOf(
"sync_generations",
"sync_consumed_generations",
"sync_acknowledgements",
"sync_causal_publications",
)
.forEach { table ->
db.rawQuery("SELECT COUNT(*) FROM $table", null).use {
assertTrue(it.moveToFirst())
assertEquals(0, it.getInt(0))
}
}
}
} finally {
migrated.close()
context.deleteDatabase(name)
}
}
@Test
fun lateSemanticFailureRollsBackWholeAndroidGeneration() {
val sourceName="generation-rollback-source-${UUID.randomUUID()}.db";val destinationName="generation-rollback-destination-${UUID.randomUUID()}.db"
val root=Files.createTempDirectory("trainlog-generation-rollback-").toFile();val source=TrainlogRepository(context,sourceName);val destination=TrainlogRepository(context,destinationName)
try{
val exercise=(source.createExercise(NewExerciseProfile("Rollback exercise",RecordingMode.SETS,TrackingMode.REPS,0)) as CreateExerciseResult.Created).exercise
assertTrue(source.saveSession(SessionDraft(listOf(SessionExerciseDraft(exercise=exercise,sets=listOf(SessionSetDraft(reps=5)))))) is SaveSessionResult.Saved)
val destinationService=SyncGenerationService(destination);val identity=destinationService.capture(root,"peer_11111111-1111-4111-8111-111111111111")
val peer=JSONObject(java.io.File(identity.stagingDirectory,"manifest.json").readText()).getJSONObject("producer").getString("peer_id")
val sourceService=SyncGenerationService(source);val captured=sourceService.capture(root,peer);val published=sourceService.publish(captured,java.io.File(root,"objects"))
val bad="{}".toByteArray();java.io.File(published,"feedback-v2.json").writeBytes(bad)
val manifest=JSONObject(java.io.File(published,"manifest.json").readText());val artifacts=manifest.getJSONArray("artifacts")
for(index in 0 until artifacts.length())if(artifacts.getJSONObject(index).getString("logical_name")=="feedback")artifacts.getJSONObject(index).put("size",bad.size).put("sha256",java.security.MessageDigest.getInstance("SHA-256").digest(bad).joinToString(""){"%02x".format(it)})
java.io.File(published,"manifest.json").writeText(manifest.toString())
assertEquals("rejected",JSONObject(destinationService.consume(published)).getString("result"))
val sourceName = "generation-rollback-source-${UUID.randomUUID()}.db"
val destinationName = "generation-rollback-destination-${UUID.randomUUID()}.db"
val root = Files.createTempDirectory("trainlog-generation-rollback-").toFile()
val source = TrainlogRepository(context, sourceName)
val destination = TrainlogRepository(context, destinationName)
try {
val exercise =
(source.createExercise(
NewExerciseProfile(
"Rollback exercise",
RecordingMode.SETS,
TrackingMode.REPS,
0,
)
) as CreateExerciseResult.Created)
.exercise
assertTrue(
source.saveSession(
SessionDraft(
listOf(
SessionExerciseDraft(
exercise = exercise,
sets = listOf(SessionSetDraft(reps = 5)),
)
)
)
) is SaveSessionResult.Saved
)
val destinationService = SyncGenerationService(destination)
val identity =
destinationService.capture(root, "peer_11111111-1111-4111-8111-111111111111")
val peer =
JSONObject(java.io.File(identity.stagingDirectory, "manifest.json").readText())
.getJSONObject("producer")
.getString("peer_id")
val sourceService = SyncGenerationService(source)
val captured = sourceService.capture(root, peer)
val published = sourceService.publish(captured, java.io.File(root, "objects"))
val bad = "{}".toByteArray()
java.io.File(published, "feedback-v2.json").writeBytes(bad)
val manifest = JSONObject(java.io.File(published, "manifest.json").readText())
val artifacts = manifest.getJSONArray("artifacts")
for (index in 0 until artifacts.length()) if (
artifacts.getJSONObject(index).getString("logical_name") == "feedback"
)
artifacts
.getJSONObject(index)
.put("size", bad.size)
.put(
"sha256",
java.security.MessageDigest.getInstance("SHA-256").digest(bad).joinToString(
""
) {
"%02x".format(it)
},
)
java.io.File(published, "manifest.json").writeText(manifest.toString())
assertEquals(
"rejected",
JSONObject(destinationService.consume(published)).getString("result"),
)
assertTrue(destination.listSessions().isEmpty())
SQLiteDatabase.openDatabase(context.getDatabasePath(destinationName).absolutePath,null,SQLiteDatabase.OPEN_READONLY).use{db->db.rawQuery("SELECT COUNT(*) FROM sync_consumed_generations WHERE result='rejected'",null).use{assertTrue(it.moveToFirst());assertEquals(1,it.getInt(0))}}
}finally{source.close();destination.close();context.deleteDatabase(sourceName);context.deleteDatabase(destinationName);root.deleteRecursively()}
SQLiteDatabase.openDatabase(
context.getDatabasePath(destinationName).absolutePath,
null,
SQLiteDatabase.OPEN_READONLY,
)
.use { db ->
db.rawQuery(
"SELECT COUNT(*) FROM sync_consumed_generations WHERE result='rejected'",
null,
)
.use {
assertTrue(it.moveToFirst())
assertEquals(1, it.getInt(0))
}
}
} finally {
source.close()
destination.close()
context.deleteDatabase(sourceName)
context.deleteDatabase(destinationName)
root.deleteRecursively()
}
}
@Test
fun causalOperationBytesRemainImmutableAcrossAndroidGenerations() {
val name="generation-causal-${UUID.randomUUID()}.db";val root=Files.createTempDirectory("trainlog-generation-causal-").toFile();val repo=TrainlogRepository(context,name)
try{
val exercise=(repo.createExercise(NewExerciseProfile("Causal generation",RecordingMode.SETS,TrackingMode.REPS,0)) as CreateExerciseResult.Created).exercise
assertTrue(repo.saveSession(SessionDraft(listOf(SessionExerciseDraft(exercise=exercise,sets=listOf(SessionSetDraft(reps=4)))))) is SaveSessionResult.Saved)
val sessionId=repo.listSessions().single().sessionId
assertTrue(repo.deleteCausally("session",sessionId,"peer_android_generation") is CausalDeleteResult.Applied)
val service=SyncGenerationService(repo);val peer="peer_33333333-3333-4333-8333-333333333333"
val first=service.capture(root,peer);val second=service.capture(root,peer)
fun operation(captured:CapturedSyncGeneration)=JSONObject(java.io.File(captured.stagingDirectory,"causal-deletions-v1.json").readText()).getJSONArray("operations").getJSONObject(0)
assertEquals(operation(first).toString(),operation(second).toString());assertTrue(operation(first).isNull("publication_context"))
SQLiteDatabase.openDatabase(context.getDatabasePath(name).absolutePath,null,SQLiteDatabase.OPEN_READONLY).use{db->
db.rawQuery("SELECT SUM(first_emission),COUNT(*)-SUM(first_emission) FROM sync_causal_publications",null).use{assertTrue(it.moveToFirst());assertEquals(1,it.getInt(0));assertEquals(1,it.getInt(1))}
}
}finally{repo.close();context.deleteDatabase(name);root.deleteRecursively()}
val name = "generation-causal-${UUID.randomUUID()}.db"
val root = Files.createTempDirectory("trainlog-generation-causal-").toFile()
val repo = TrainlogRepository(context, name)
try {
val exercise =
(repo.createExercise(
NewExerciseProfile(
"Causal generation",
RecordingMode.SETS,
TrackingMode.REPS,
0,
)
) as CreateExerciseResult.Created)
.exercise
assertTrue(
repo.saveSession(
SessionDraft(
listOf(
SessionExerciseDraft(
exercise = exercise,
sets = listOf(SessionSetDraft(reps = 4)),
)
)
)
) is SaveSessionResult.Saved
)
val sessionId = repo.listSessions().single().sessionId
assertTrue(
repo.deleteCausally("session", sessionId, "peer_android_generation")
is CausalDeleteResult.Applied
)
val service = SyncGenerationService(repo)
val peer = "peer_33333333-3333-4333-8333-333333333333"
val first = service.capture(root, peer)
val second = service.capture(root, peer)
fun operation(captured: CapturedSyncGeneration) =
JSONObject(
java.io
.File(captured.stagingDirectory, "causal-deletions-v1.json")
.readText()
)
.getJSONArray("operations")
.getJSONObject(0)
assertEquals(operation(first).toString(), operation(second).toString())
assertTrue(operation(first).isNull("publication_context"))
SQLiteDatabase.openDatabase(
context.getDatabasePath(name).absolutePath,
null,
SQLiteDatabase.OPEN_READONLY,
)
.use { db ->
db.rawQuery(
"SELECT SUM(first_emission),COUNT(*)-SUM(first_emission) FROM sync_causal_publications",
null,
)
.use {
assertTrue(it.moveToFirst())
assertEquals(1, it.getInt(0))
assertEquals(1, it.getInt(1))
}
}
} finally {
repo.close()
context.deleteDatabase(name)
root.deleteRecursively()
}
}
@Test
fun androidCaptureUsesOneSnapshotAgainstControlledWriter() {
val name="generation-snapshot-${UUID.randomUUID()}.db";val repo=TrainlogRepository(context,name)
try{
val first=(repo.createExercise(NewExerciseProfile("Snapshot A",RecordingMode.SETS,TrackingMode.REPS,0)) as CreateExerciseResult.Created).exercise
assertTrue(repo.saveSession(SessionDraft(listOf(SessionExerciseDraft(exercise=first,sets=listOf(SessionSetDraft(reps=3)))))) is SaveSessionResult.Saved)
val attempted=CountDownLatch(1);var writer:Thread?=null
val captured=repo.captureSyncGenerationArtifacts{
writer=Thread{
attempted.countDown()
repo.createExercise(NewExerciseProfile("Snapshot B",RecordingMode.SETS,TrackingMode.REPS,0))
}.also{it.start()}
assertTrue(attempted.await(5,TimeUnit.SECONDS))
}
val completedWriter=checkNotNull(writer)
completedWriter.join(5000);assertTrue(!completedWriter.isAlive)
assertEquals(1,JSONObject(captured.getValue("catalog")).getJSONArray("exercises").length())
assertEquals(1,JSONObject(captured.getValue("exercise-profile-state")).getJSONArray("exercises").length())
assertEquals(2,repo.listExercises().size)
}finally{repo.close();context.deleteDatabase(name)}
val name = "generation-snapshot-${UUID.randomUUID()}.db"
val repo = TrainlogRepository(context, name)
try {
val first =
(repo.createExercise(
NewExerciseProfile("Snapshot A", RecordingMode.SETS, TrackingMode.REPS, 0)
) as CreateExerciseResult.Created)
.exercise
assertTrue(
repo.saveSession(
SessionDraft(
listOf(
SessionExerciseDraft(
exercise = first,
sets = listOf(SessionSetDraft(reps = 3)),
)
)
)
) is SaveSessionResult.Saved
)
val attempted = CountDownLatch(1)
var writer: Thread? = null
val captured =
repo.captureSyncGenerationArtifacts {
writer =
Thread {
attempted.countDown()
repo.createExercise(
NewExerciseProfile(
"Snapshot B",
RecordingMode.SETS,
TrackingMode.REPS,
0,
)
)
}
.also { it.start() }
assertTrue(attempted.await(5, TimeUnit.SECONDS))
}
val completedWriter = checkNotNull(writer)
completedWriter.join(5000)
assertTrue(!completedWriter.isAlive)
assertEquals(
1,
JSONObject(captured.getValue("catalog")).getJSONArray("exercises").length(),
)
assertEquals(
1,
JSONObject(captured.getValue("exercise-profile-state"))
.getJSONArray("exercises")
.length(),
)
assertEquals(2, repo.listExercises().size)
} finally {
repo.close()
context.deleteDatabase(name)
}
}
@Test
fun androidGenerationCapturePublishConsumeAndAckSurviveReopen() {
val sourceName="generation-source-${UUID.randomUUID()}.db"
val destinationName="generation-destination-${UUID.randomUUID()}.db"
val desktopDestinationName="generation-desktop-destination-${UUID.randomUUID()}.db"
val root=Files.createTempDirectory("trainlog-generation-android-").toFile()
var source=TrainlogRepository(context,sourceName);var destination=TrainlogRepository(context,destinationName)
var desktopDestination=TrainlogRepository(context,desktopDestinationName)
val sourceName = "generation-source-${UUID.randomUUID()}.db"
val destinationName = "generation-destination-${UUID.randomUUID()}.db"
val desktopDestinationName = "generation-desktop-destination-${UUID.randomUUID()}.db"
val root = Files.createTempDirectory("trainlog-generation-android-").toFile()
var source = TrainlogRepository(context, sourceName)
var destination = TrainlogRepository(context, destinationName)
var desktopDestination = TrainlogRepository(context, desktopDestinationName)
try {
val created=source.createExercise(NewExerciseProfile("Generation exercise",RecordingMode.SETS,TrackingMode.REPS,0)) as CreateExerciseResult.Created
val saved = source.saveSession(
SessionDraft(listOf(SessionExerciseDraft(
exercise=created.exercise,
sets=listOf(SessionSetDraft(reps=7,weightKg=21.5)),
))),
)
val created =
source.createExercise(
NewExerciseProfile(
"Generation exercise",
RecordingMode.SETS,
TrackingMode.REPS,
0,
)
) as CreateExerciseResult.Created
val saved =
source.saveSession(
SessionDraft(
listOf(
SessionExerciseDraft(
exercise = created.exercise,
sets = listOf(SessionSetDraft(reps = 7, weightKg = 21.5)),
)
)
)
)
assertTrue(saved is SaveSessionResult.Saved)
val destinationService=SyncGenerationService(destination)
val identityCapture=destinationService.capture(root,"peer_11111111-1111-4111-8111-111111111111")
val destinationPeer=JSONObject(java.io.File(identityCapture.stagingDirectory,"manifest.json").readText()).getJSONObject("producer").getString("peer_id")
val sourceService=SyncGenerationService(source)
val captured=sourceService.capture(root,destinationPeer)
val published=sourceService.publish(captured,java.io.File(root,"objects"))
val before=published.listFiles()!!.associate{it.name to it.readBytes().contentHashCode()}
assertEquals(published,sourceService.publish(captured,java.io.File(root,"objects")))
assertEquals(before,published.listFiles()!!.associate{it.name to it.readBytes().contentHashCode()})
val ack=destinationService.consume(published)
assertEquals("acknowledged",sourceService.acceptAcknowledgement(ack.toByteArray()))
source.close();destination.close()
source=TrainlogRepository(context,sourceName);destination=TrainlogRepository(context,destinationName)
assertEquals(1,destination.listSessions().size)
assertEquals("unchanged",SyncGenerationService(source).acceptAcknowledgement(ack.toByteArray()))
assertEquals(ack,SyncGenerationService(destination).consume(published))
val destinationService = SyncGenerationService(destination)
val identityCapture =
destinationService.capture(root, "peer_11111111-1111-4111-8111-111111111111")
val destinationPeer =
JSONObject(
java.io.File(identityCapture.stagingDirectory, "manifest.json").readText()
)
.getJSONObject("producer")
.getString("peer_id")
val sourceService = SyncGenerationService(source)
val captured = sourceService.capture(root, destinationPeer)
val published = sourceService.publish(captured, java.io.File(root, "objects"))
val before =
published.listFiles()!!.associate { it.name to it.readBytes().contentHashCode() }
assertEquals(published, sourceService.publish(captured, java.io.File(root, "objects")))
assertEquals(
before,
published.listFiles()!!.associate { it.name to it.readBytes().contentHashCode() },
)
val ack = destinationService.consume(published)
assertEquals("acknowledged", sourceService.acceptAcknowledgement(ack.toByteArray()))
source.close()
destination.close()
source = TrainlogRepository(context, sourceName)
destination = TrainlogRepository(context, destinationName)
assertEquals(1, destination.listSessions().size)
assertEquals(
"unchanged",
SyncGenerationService(source).acceptAcknowledgement(ack.toByteArray()),
)
assertEquals(ack, SyncGenerationService(destination).consume(published))
// A desktop-created mutation uses the public C persistence API,
// then the production desktop generation entry point. This is not
// an Android generation re-exported through desktop.
val repositoryRoot=generateSequence(java.io.File(checkNotNull(System.getProperty("user.dir")))){it.parentFile}
.first{java.io.File(it,"tools/sync_generation_exchange.py").isFile}
val desktopDb=java.io.File(root,"desktop-source.sqlite")
fun run(vararg command:String):String{
val process=ProcessBuilder(*command).directory(repositoryRoot).redirectErrorStream(true).start()
val text=process.inputStream.bufferedReader().readText()
assertEquals("command failed: ${command.joinToString(" ")}\n$text",0,process.waitFor())
val repositoryRoot =
generateSequence(java.io.File(checkNotNull(System.getProperty("user.dir")))) {
it.parentFile
}
.first { java.io.File(it, "tools/sync_generation_exchange.py").isFile }
val desktopDb = java.io.File(root, "desktop-source.sqlite")
fun run(vararg command: String): String {
val process =
ProcessBuilder(*command)
.directory(repositoryRoot)
.redirectErrorStream(true)
.start()
val text = process.inputStream.bufferedReader().readText()
assertEquals(
"command failed: ${command.joinToString(" ")}\n$text",
0,
process.waitFor(),
)
return text
}
val desktopConsumerDb=java.io.File(root,"desktop-consumer.sqlite")
run(java.io.File(repositoryRoot,"build/tui/sync-generation-fixture").absolutePath,desktopConsumerDb.absolutePath)
val desktopPeer=Regex("PEER_ID=(peer_[^\\s]+)").find(run("python3",java.io.File(repositoryRoot,"tools/sync_generation_exchange.py").absolutePath,"peer-id","--database",desktopConsumerDb.absolutePath))!!.groupValues[1]
val androidToDesktop=sourceService.capture(root,desktopPeer)
val androidPublished=sourceService.publish(androidToDesktop,java.io.File(root,"android-desktop-objects"))
val androidAckFile=java.io.File(root,"android-desktop-ack.json")
run("python3",java.io.File(repositoryRoot,"tools/sync_generation_exchange.py").absolutePath,"consume-desktop",androidPublished.absolutePath,"--database",desktopConsumerDb.absolutePath,"--ack-output",androidAckFile.absolutePath)
assertEquals("acknowledged",sourceService.acceptAcknowledgement(androidAckFile.readBytes()))
val androidSessionId=source.listSessions().single().sessionId
assertEquals("1",run("sqlite3",desktopConsumerDb.absolutePath,"SELECT count(*) FROM sessions WHERE session_id='$androidSessionId';").trim())
val desktopConsumerDb = java.io.File(root, "desktop-consumer.sqlite")
run(
java.io.File(repositoryRoot, "build/tui/sync-generation-fixture").absolutePath,
desktopConsumerDb.absolutePath,
)
val desktopPeer =
Regex("PEER_ID=(peer_[^\\s]+)")
.find(
run(
"python3",
java.io
.File(repositoryRoot, "tools/sync_generation_exchange.py")
.absolutePath,
"peer-id",
"--database",
desktopConsumerDb.absolutePath,
)
)!!
.groupValues[1]
val androidToDesktop = sourceService.capture(root, desktopPeer)
val androidPublished =
sourceService.publish(
androidToDesktop,
java.io.File(root, "android-desktop-objects"),
)
val androidAckFile = java.io.File(root, "android-desktop-ack.json")
run(
"python3",
java.io.File(repositoryRoot, "tools/sync_generation_exchange.py").absolutePath,
"consume-desktop",
androidPublished.absolutePath,
"--database",
desktopConsumerDb.absolutePath,
"--ack-output",
androidAckFile.absolutePath,
)
assertEquals(
"acknowledged",
sourceService.acceptAcknowledgement(androidAckFile.readBytes()),
)
val androidSessionId = source.listSessions().single().sessionId
assertEquals(
"1",
run(
"sqlite3",
desktopConsumerDb.absolutePath,
"SELECT count(*) FROM sessions WHERE session_id='$androidSessionId';",
)
.trim(),
)
run(java.io.File(repositoryRoot,"build/tui/sync-generation-fixture").absolutePath,desktopDb.absolutePath)
val androidPeerCapture=SyncGenerationService(desktopDestination).capture(root,"peer_22222222-2222-4222-8222-222222222222")
val androidPeer=JSONObject(java.io.File(androidPeerCapture.stagingDirectory,"manifest.json").readText()).getJSONObject("producer").getString("peer_id")
val desktopOwned=java.io.File(root,"desktop-owned");val desktopObjects=java.io.File(root,"desktop-objects")
val captureText=run("python3",java.io.File(repositoryRoot,"tools/sync_generation_exchange.py").absolutePath,"capture-desktop","--database",desktopDb.absolutePath,"--owned-root",desktopOwned.absolutePath,"--consumer-peer",androidPeer)
val desktopGeneration=Regex("generation_id=(gen_[^ ]+)").find(captureText)!!.groupValues[1]
run("python3",java.io.File(repositoryRoot,"tools/sync_generation_exchange.py").absolutePath,"publish","--database",desktopDb.absolutePath,"--generation-id",desktopGeneration,"--object-root",desktopObjects.absolutePath)
val desktopPublished=java.io.File(desktopObjects,"generations/$desktopGeneration")
val desktopAck=SyncGenerationService(desktopDestination).consume(desktopPublished)
val desktopAckFile=java.io.File(root,"desktop-ack.json").apply{writeText(desktopAck)}
assertTrue(run("python3",java.io.File(repositoryRoot,"tools/sync_generation_exchange.py").absolutePath,"accept-ack",desktopAckFile.absolutePath,"--database",desktopDb.absolutePath).contains("ACK_ACCEPT=acknowledged"))
assertTrue(desktopDestination.listSessions().any{it.sessionId=="se_99999999-9999-4999-8999-999999999999"})
run(
java.io.File(repositoryRoot, "build/tui/sync-generation-fixture").absolutePath,
desktopDb.absolutePath,
)
val androidPeerCapture =
SyncGenerationService(desktopDestination)
.capture(root, "peer_22222222-2222-4222-8222-222222222222")
val androidPeer =
JSONObject(
java.io
.File(androidPeerCapture.stagingDirectory, "manifest.json")
.readText()
)
.getJSONObject("producer")
.getString("peer_id")
val desktopOwned = java.io.File(root, "desktop-owned")
val desktopObjects = java.io.File(root, "desktop-objects")
val captureText =
run(
"python3",
java.io.File(repositoryRoot, "tools/sync_generation_exchange.py").absolutePath,
"capture-desktop",
"--database",
desktopDb.absolutePath,
"--owned-root",
desktopOwned.absolutePath,
"--consumer-peer",
androidPeer,
)
val desktopGeneration =
Regex("generation_id=(gen_[^ ]+)").find(captureText)!!.groupValues[1]
run(
"python3",
java.io.File(repositoryRoot, "tools/sync_generation_exchange.py").absolutePath,
"publish",
"--database",
desktopDb.absolutePath,
"--generation-id",
desktopGeneration,
"--object-root",
desktopObjects.absolutePath,
)
val desktopPublished = java.io.File(desktopObjects, "generations/$desktopGeneration")
val desktopAck = SyncGenerationService(desktopDestination).consume(desktopPublished)
val desktopAckFile =
java.io.File(root, "desktop-ack.json").apply { writeText(desktopAck) }
assertTrue(
run(
"python3",
java.io
.File(repositoryRoot, "tools/sync_generation_exchange.py")
.absolutePath,
"accept-ack",
desktopAckFile.absolutePath,
"--database",
desktopDb.absolutePath,
)
.contains("ACK_ACCEPT=acknowledged")
)
assertTrue(
desktopDestination.listSessions().any {
it.sessionId == "se_99999999-9999-4999-8999-999999999999"
}
)
} finally {
source.close();destination.close();desktopDestination.close();context.deleteDatabase(sourceName);context.deleteDatabase(destinationName);context.deleteDatabase(desktopDestinationName);root.deleteRecursively()
source.close()
destination.close()
desktopDestination.close()
context.deleteDatabase(sourceName)
context.deleteDatabase(destinationName)
context.deleteDatabase(desktopDestinationName)
root.deleteRecursively()
}
}
}

View file

@ -6,6 +6,11 @@ Code must optimize for readability, correctness, and maintainability.
Avoid clever code when a straightforward implementation is easier to verify.
These rules apply equally to production code, tests, I/O doubles, migration
bridges, and repository tooling. Code must not be manually compacted to save
lines or prompt tokens. A grouped mission remains compatible with small,
reviewable commits.
## 2. C standard
The TUI uses C17.
@ -91,11 +96,33 @@ Good:
## 6. Formatting
The repository currently has no canonical `.clang-format` file. Do not apply a
bulk formatter with tool-default behavior; introducing a format definition and
any broad normalization requires a dedicated, reviewable change.
The repository `.clang-format` defines the C presentation used by normalized
files. It targets 100 columns, four-space indentation, one statement per line,
explicit control-flow braces, vertical long parameter lists, and no short
function or control body on one line. Contract literals may exceed the target
when splitting them would make the protocol less reviewable.
Formatting changes should not be mixed with unrelated semantic changes when avoidable.
Do not apply the formatter blindly to the entire historical tree. A change
normalizes the files it owns and checks that explicit scope. The reproducible,
non-mutating check for the currently normalized C readability scope is:
```bash
clang-format --dry-run --Werror \
tui/src/dashboard_layout.c \
tui/src/generation_mtp.c \
tui/src/json_writer.c \
tui/src/web_dashboard.c \
tui/src/web_dashboard_json.c \
tui/include/trainlog/generation_mtp.h \
tui/tools/generation_mtp_adapter.c \
tui/tests/generation_mtp_adapter_double.c \
tui/tests/test_generation_mtp.c \
tui/tests/test_web_server.c
```
Formatting changes should not be mixed with unrelated semantic changes when
avoidable. Review readability explicitly before the final commit, including
tests and cleanup/error paths.
## 7. Error handling
@ -130,3 +157,7 @@ The Android side should favor:
- small view models;
- no unnecessary framework abstraction;
- clear serialization types matching the Trainlog JSON contract.
Python and Kotlin follow the same human-readability requirement: avoid
single-line compound statements, compressed exception handling, and opaque
chains of unrelated mutations. Formatters are aids, not substitutes for review.

View file

@ -10,6 +10,12 @@ and deployment are not complete.
Snapshot date: **2026-09-17**.
`TRAINLOG_CODE_READABILITY_V1` normalizes the recent generation-MTP,
generation/ACK, Android-backup, Web Dashboard serialization, tests, doubles and
packaging workflows without changing their contracts. The scoped C formatter
check is canonical for those files only; it does not claim that the historical
repository has been globally reformatted.
This document owns the current implemented state. Historical checkpoints and
closed incidents belong in [reviews](reviews/) and the
[changelog](../CHANGELOG.md).

View file

@ -1307,3 +1307,26 @@ the analogous test on the exact schema-v17 source and feeds its archive to
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.
## Readability validation
The normalized C scope is checked non-mutatively with the explicit
`clang-format --dry-run --Werror` command in `docs/coding_style.md`. Clang C17,
the normal Meson inventory and the ASan/UBSan inventory preserve the existing
behavioral assertions. Python syntax/orchestrator tests, Android generation and
backup tests, the I/O-boundary MTP browser proof, JSON/import validators and
the isolated harness cover the other normalized sources.
GCC 16.2.1 now compiles beyond the former `misleading-indentation` failures in
`json_writer.c`, `web_dashboard_json.c`, `web_dashboard.c`,
`dashboard_layout.c`, and `generation_mtp.c`. The separate strict build remains
blocked by distinct pre-existing `format-truncation` diagnostics in the Web
accepted-response buffer and bounded test path buffers. Warning policy remains
unchanged; GCC is not reported as green.
The readability closeout preserves the **81/81** normal Meson and **81/81**
ASan/UBSan inventories. The isolated Android run reports **220 tests: 215
passed, five skipped, zero failures/errors**, and `assembleDebug` succeeds on
JDK 17. The real Firefox/object-I/O-boundary MTP conversation also passes after
formatting. A source comparison against the pre-readability commit confirms
that the scoped test names and assertion counts are unchanged.

View file

@ -1,43 +1,137 @@
import json, os, signal, subprocess, tempfile, time, unittest, uuid
from pathlib import Path
ROOT=Path(__file__).resolve().parents[1]
ORCHESTRATOR=ROOT/"tools/sync_orchestrator.py"
def identity(prefix): return prefix+str(uuid.uuid4())
ROOT = Path(__file__).resolve().parents[1]
ORCHESTRATOR = ROOT / "tools/sync_orchestrator.py"
def identity(prefix):
return prefix + str(uuid.uuid4())
class OrchestratorTest(unittest.TestCase):
def fixture(self, root, **overrides):
db=root/"trainlog.db";db.touch();state=Path(str(db)+".sync-run.json")
run=identity("sy_");request=identity("sy_")
state.write_text(json.dumps({"run_id":run,"request_id":request,"trigger":"web","phase":"requested","progress_revision":1,"result":"running"}))
config={"format":"trainlog-sync-orchestrator-config","version":1,"enabled":True,"mode":"directory","expected_peer_id":identity("peer_"),"transport_root":str(root/"transport"),"owned_root":str(root/"owned"),"timeout_seconds":30}|overrides
(root/"transport").mkdir();(root/"owned").mkdir();path=root/"config.json";path.write_text(json.dumps(config))
return db,state,path,run,request
def invoke(self, executable, values):
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":"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")
def test_stdout_bound_is_enforced_before_complete_accumulation(self):
with tempfile.TemporaryDirectory() as directory:
root=Path(directory);sandbox=root/"program";sandbox.mkdir();(sandbox/"sync_orchestrator.py").write_bytes(ORCHESTRATOR.read_bytes())
(sandbox/"sync_peer_worker.py").write_text("import sys,time\nsys.stdout.write('x'*70000);sys.stdout.flush();time.sleep(30)\n")
values=self.fixture(root);result=self.invoke(sandbox/"sync_orchestrator.py",values)
self.assertEqual(result.returncode,2);final=json.loads(values[1].read_text());self.assertIn("exceeds 64 KiB",final["diagnostic"])
def test_shutdown_reaps_ignoring_helper_and_descendant(self):
with tempfile.TemporaryDirectory() as directory:
root=Path(directory);sandbox=root/"program";sandbox.mkdir();pidfile=root/"pids";(sandbox/"sync_orchestrator.py").write_bytes(ORCHESTRATOR.read_bytes())
(sandbox/"sync_peer_worker.py").write_text("import os,signal,time\nsignal.signal(signal.SIGTERM,signal.SIG_IGN)\npid=os.fork()\nif pid==0:\n signal.signal(signal.SIGTERM,signal.SIG_IGN);open(%r,'a').write(str(os.getpid())+'\\n');time.sleep(60)\nelse:\n open(%r,'a').write(str(os.getpid())+'\\n');time.sleep(60)\n"%(str(pidfile),str(pidfile)))
values=self.fixture(root);db,state,config,run,request=values
process=subprocess.Popen(["python3",str(sandbox/"sync_orchestrator.py"),"--database",str(db),"--state",str(state),"--config",str(config),"--run-id",run,"--request-id",request])
deadline=time.monotonic()+5
while not pidfile.is_file() and time.monotonic()<deadline: time.sleep(.02)
process.send_signal(signal.SIGTERM);self.assertEqual(process.wait(timeout=10),2)
for pid in map(int,pidfile.read_text().split()):
with self.assertRaises(ProcessLookupError): os.kill(pid,0)
self.assertEqual(json.loads(state.read_text())["phase"],"interrupted")
def fixture(self, root, **overrides):
db = root / "trainlog.db"
db.touch()
state = Path(str(db) + ".sync-run.json")
run = identity("sy_")
request = identity("sy_")
state.write_text(
json.dumps(
{
"run_id": run,
"request_id": request,
"trigger": "web",
"phase": "requested",
"progress_revision": 1,
"result": "running",
}
)
)
config = {
"format": "trainlog-sync-orchestrator-config",
"version": 1,
"enabled": True,
"mode": "directory",
"expected_peer_id": identity("peer_"),
"transport_root": str(root / "transport"),
"owned_root": str(root / "owned"),
"timeout_seconds": 30,
} | overrides
(root / "transport").mkdir()
(root / "owned").mkdir()
path = root / "config.json"
path.write_text(json.dumps(config))
return db, state, path, run, request
if __name__=="__main__": unittest.main()
def invoke(self, executable, values):
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": "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")
def test_stdout_bound_is_enforced_before_complete_accumulation(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
sandbox = root / "program"
sandbox.mkdir()
(sandbox / "sync_orchestrator.py").write_bytes(ORCHESTRATOR.read_bytes())
(sandbox / "sync_peer_worker.py").write_text(
"import sys,time\nsys.stdout.write('x'*70000);sys.stdout.flush();time.sleep(30)\n"
)
values = self.fixture(root)
result = self.invoke(sandbox / "sync_orchestrator.py", values)
self.assertEqual(result.returncode, 2)
final = json.loads(values[1].read_text())
self.assertIn("exceeds 64 KiB", final["diagnostic"])
def test_shutdown_reaps_ignoring_helper_and_descendant(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
sandbox = root / "program"
sandbox.mkdir()
pidfile = root / "pids"
(sandbox / "sync_orchestrator.py").write_bytes(ORCHESTRATOR.read_bytes())
(sandbox / "sync_peer_worker.py").write_text(
"import os,signal,time\nsignal.signal(signal.SIGTERM,signal.SIG_IGN)\npid=os.fork()\nif pid==0:\n signal.signal(signal.SIGTERM,signal.SIG_IGN);open(%r,'a').write(str(os.getpid())+'\\n');time.sleep(60)\nelse:\n open(%r,'a').write(str(os.getpid())+'\\n');time.sleep(60)\n"
% (str(pidfile), str(pidfile))
)
values = self.fixture(root)
db, state, config, run, request = values
process = subprocess.Popen(
[
"python3",
str(sandbox / "sync_orchestrator.py"),
"--database",
str(db),
"--state",
str(state),
"--config",
str(config),
"--run-id",
run,
"--request-id",
request,
]
)
deadline = time.monotonic() + 5
while not pidfile.is_file() and time.monotonic() < deadline:
time.sleep(0.02)
process.send_signal(signal.SIGTERM)
self.assertEqual(process.wait(timeout=10), 2)
for pid in map(int, pidfile.read_text().split()):
with self.assertRaises(ProcessLookupError):
os.kill(pid, 0)
self.assertEqual(json.loads(state.read_text())["phase"], "interrupted")
if __name__ == "__main__":
unittest.main()

View file

@ -1,83 +1,211 @@
#!/usr/bin/env python3
"""Real Firefox and Android production services in one correlated conversation."""
import json, os, signal, socket, sqlite3, subprocess, tempfile, time, unittest
from pathlib import Path
ROOT=Path(__file__).resolve().parents[1]
GECKO=Path(os.environ.get("TRAINLOG_GECKODRIVER","/home/fy59/.cache/trainlog/web-e2e-tools/geckodriver"))
ROOT = Path(__file__).resolve().parents[1]
GECKO = Path(
os.environ.get("TRAINLOG_GECKODRIVER", "/home/fy59/.cache/trainlog/web-e2e-tools/geckodriver")
)
def await_condition(predicate, seconds=30):
deadline=time.monotonic()+seconds
while time.monotonic()<deadline:
value=predicate()
if value: return value
time.sleep(.025)
raise AssertionError("condition did not become true")
deadline = time.monotonic() + seconds
while time.monotonic() < deadline:
value = predicate()
if value:
return value
time.sleep(0.025)
raise AssertionError("condition did not become true")
class BrowserSyncTest(unittest.TestCase):
def test_button_starts_worker_reconnects_and_displays_committed_draft(self):
if not GECKO.is_file(): self.skipTest("pinned geckodriver unavailable")
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.firefox.service import Service
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(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: (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":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:
for _ in range(100):
with socket.socket() as probe:
if probe.connect_ex(("127.0.0.1",port))==0: break
time.sleep(.05)
options=webdriver.FirefoxOptions();options.add_argument("-headless")
driver=webdriver.Firefox(options=options,service=Service(str(GECKO)))
driver.get(f"http://127.0.0.1:{port}/")
wait=WebDriverWait(driver,15)
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")
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)
release.touch()
final=await_condition(lambda: json.loads(state_path.read_text()) if json.loads(state_path.read_text()).get("phase") in ("completed","failed","interrupted") else None)
self.assertEqual(final["phase"],"completed",f"state={final}\nandroid={((root/'android.log').read_text(errors='replace'))}")
wait.until(lambda d:"Synchronisation confirmée" in d.page_source)
summary=driver.find_element(By.XPATH,"//summary[contains(.,'Synchronisation confirmée')]");summary.click()
wait.until(lambda d:"Brouillons reçus" in d.page_source and "active" in d.page_source)
driver.refresh();wait.until(lambda d:"Synchronisation confirmée" in d.page_source)
state=json.loads(Path(str(xdg/"trainlog"/"trainlog.db")+".sync-run.json").read_text())
self.assertEqual(state["phase"],"completed")
self.assertEqual(android.wait(timeout=30),0,(root/"android.log").read_text(errors="replace"))
finally:
if driver is not None: driver.quit()
os.killpg(server.pid,signal.SIGTERM)
server.wait(timeout=10)
if android.poll() is None:
os.killpg(android.pid,signal.SIGTERM);android.wait(timeout=10)
android_log.close()
def test_button_starts_worker_reconnects_and_displays_committed_draft(self):
if not GECKO.is_file():
self.skipTest("pinned geckodriver unavailable")
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.firefox.service import Service
from selenium.webdriver.support.ui import WebDriverWait
if __name__=="__main__":unittest.main()
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(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: (
(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": 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:
for _ in range(100):
with socket.socket() as probe:
if probe.connect_ex(("127.0.0.1", port)) == 0:
break
time.sleep(0.05)
options = webdriver.FirefoxOptions()
options.add_argument("-headless")
driver = webdriver.Firefox(options=options, service=Service(str(GECKO)))
driver.get(f"http://127.0.0.1:{port}/")
wait = WebDriverWait(driver, 15)
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")
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
)
release.touch()
final = await_condition(
lambda: (
json.loads(state_path.read_text())
if json.loads(state_path.read_text()).get("phase")
in ("completed", "failed", "interrupted")
else None
)
)
self.assertEqual(
final["phase"],
"completed",
f"state={final}\nandroid={((root/'android.log').read_text(errors='replace'))}",
)
wait.until(lambda d: "Synchronisation confirmée" in d.page_source)
summary = driver.find_element(
By.XPATH, "//summary[contains(.,'Synchronisation confirmée')]"
)
summary.click()
wait.until(
lambda d: "Brouillons reçus" in d.page_source and "active" in d.page_source
)
driver.refresh()
wait.until(lambda d: "Synchronisation confirmée" in d.page_source)
state = json.loads(
Path(str(xdg / "trainlog" / "trainlog.db") + ".sync-run.json").read_text()
)
self.assertEqual(state["phase"], "completed")
self.assertEqual(
android.wait(timeout=30), 0, (root / "android.log").read_text(errors="replace")
)
finally:
if driver is not None:
driver.quit()
os.killpg(server.pid, signal.SIGTERM)
server.wait(timeout=10)
if android.poll() is None:
os.killpg(android.pid, signal.SIGTERM)
android.wait(timeout=10)
android_log.close()
if __name__ == "__main__":
unittest.main()

View file

@ -12,26 +12,70 @@ 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) })
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()
}
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()
}
override fun onDestroy() { repository.close(); super.onDestroy() }
}

View file

@ -19,26 +19,75 @@ import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class) @Config(sdk=[35])
@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)
@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)}
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)
}
}
}

View file

@ -1,42 +1,112 @@
#!/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"
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 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")
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)
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," <activity\n android:name=\".MainActivity\"", " <activity android:name=\".data.BackupBridgeActivity\" android:exported=\"true\" android:label=\"Trainlog Backup\">\n <intent-filter><action android:name=\"android.intent.action.MAIN\"/><category android:name=\"android.intent.category.LAUNCHER\"/></intent-filter>\n </activity>\n <activity\n android:name=\".MainActivity\"")
gradle=android/"app/build.gradle.kts"
replace(gradle,"versionCode = 3","versionCode = providers.environmentVariable(\"TRAINLOG_BRIDGE_VERSION_CODE\").orNull?.toInt() ?: 4")
env=os.environ.copy();env["JAVA_HOME"]=args.jdk;env["TRAINLOG_BRIDGE_VERSION_CODE"]=env.get("TRAINLOG_BRIDGE_VERSION_CODE","4");env["TRAINLOG_BRIDGE_BACKUP_OUTPUT"]=str(args.output/"schema17-synthetic.tlbackup")
subprocess.run([str(android/"gradlew"),"--no-daemon",":app:testDebugUnitTest","--tests","com.labfytools.trainlog.data.BackupBridgeServiceTest",":app:assembleDebug"],cwd=android,env=env,check=True)
migration_env=os.environ.copy();migration_env["JAVA_HOME"]=args.jdk;migration_env["TRAINLOG_BRIDGE_BACKUP_INPUT"]=env["TRAINLOG_BRIDGE_BACKUP_OUTPUT"]
subprocess.run([str(ROOT/"android/gradlew"),"--no-daemon",":app:testDebugUnitTest","--tests","com.labfytools.trainlog.data.BridgeBackupMigrationTest","--rerun-tasks"],cwd=ROOT/"android",env=migration_env,check=True)
print(f"BRIDGE_BASE={BASE}");print(f"BRIDGE_RECIPE={subprocess.check_output(['git','rev-parse','HEAD'],cwd=ROOT,text=True).strip()}")
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,
' <activity\n android:name=".MainActivity"',
' <activity android:name=".data.BackupBridgeActivity" android:exported="true" android:label="Trainlog Backup">\n <intent-filter><action android:name="android.intent.action.MAIN"/><category android:name="android.intent.category.LAUNCHER"/></intent-filter>\n </activity>\n <activity\n android:name=".MainActivity"',
)
gradle = android / "app/build.gradle.kts"
replace(
gradle,
"versionCode = 3",
'versionCode = providers.environmentVariable("TRAINLOG_BRIDGE_VERSION_CODE").orNull?.toInt() ?: 4',
)
env = os.environ.copy()
env["JAVA_HOME"] = args.jdk
env["TRAINLOG_BRIDGE_VERSION_CODE"] = env.get("TRAINLOG_BRIDGE_VERSION_CODE", "4")
env["TRAINLOG_BRIDGE_BACKUP_OUTPUT"] = str(args.output / "schema17-synthetic.tlbackup")
subprocess.run(
[
str(android / "gradlew"),
"--no-daemon",
":app:testDebugUnitTest",
"--tests",
"com.labfytools.trainlog.data.BackupBridgeServiceTest",
":app:assembleDebug",
],
cwd=android,
env=env,
check=True,
)
migration_env = os.environ.copy()
migration_env["JAVA_HOME"] = args.jdk
migration_env["TRAINLOG_BRIDGE_BACKUP_INPUT"] = env["TRAINLOG_BRIDGE_BACKUP_OUTPUT"]
subprocess.run(
[
str(ROOT / "android/gradlew"),
"--no-daemon",
":app:testDebugUnitTest",
"--tests",
"com.labfytools.trainlog.data.BridgeBackupMigrationTest",
"--rerun-tasks",
],
cwd=ROOT / "android",
env=migration_env,
check=True,
)
print(f"BRIDGE_BASE={BASE}")
print(
f"BRIDGE_RECIPE={subprocess.check_output(['git','rev-parse','HEAD'],cwd=ROOT,text=True).strip()}"
)
print(f"BRIDGE_APK={android/'app/build/outputs/apk/debug/app-debug.apk'}")
if __name__=="__main__": main()
if __name__ == "__main__":
main()

View file

@ -1,5 +1,6 @@
#!/usr/bin/env python3
"""Stage a reproducible, non-secret Trainlog desktop synchronization candidate."""
import argparse
import hashlib
import json
@ -10,15 +11,25 @@ from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
RUNTIME_TOOLS = [
"sync_orchestrator.py", "sync_peer_worker.py", "sync_generation_exchange.py",
"trainlog_sqlite.py", "validate_json.py", "import_mobile_export.py",
"import_exercise_aliases.py", "import_equipment_definitions.py",
"import_equipment_associations.py", "import_exercise_body_zones.py",
"import_training_feedback.py", "execution_draft_exchange.py",
"causal_delete_exchange.py", "import_exercise_profile_state.py",
"sync_orchestrator.py",
"sync_peer_worker.py",
"sync_generation_exchange.py",
"trainlog_sqlite.py",
"validate_json.py",
"import_mobile_export.py",
"import_exercise_aliases.py",
"import_equipment_definitions.py",
"import_equipment_associations.py",
"import_exercise_body_zones.py",
"import_training_feedback.py",
"execution_draft_exchange.py",
"causal_delete_exchange.py",
"import_exercise_profile_state.py",
"export_exercise_profile_state.py",
"export_pc_mobile.py", "export_equipment_definitions.py",
"export_exercise_body_zones.py", "export_training_feedback.py",
"export_pc_mobile.py",
"export_equipment_definitions.py",
"export_exercise_body_zones.py",
"export_training_feedback.py",
]
@ -54,20 +65,53 @@ def main() -> int:
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\"\nexport TRAINLOG_SYNC_MTP_ADAPTER=\"$root/libexec/trainlog-generation-mtp-adapter\"\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,
"source_commit": subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=ROOT, text=True).strip(),
"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", "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],
"format": "trainlog-sync-candidate-inventory",
"version": 1,
"source_commit": subprocess.check_output(
["git", "rev-parse", "HEAD"], cwd=ROOT, text=True
).strip(),
"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",
"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")
(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)

View file

@ -5,6 +5,7 @@ The HTTP adapter starts this executable with fixed arguments. Peer transport is
selected only by a trusted local configuration file; request JSON can never
choose commands, paths, databases, capabilities, or causal policy.
"""
from __future__ import annotations
import argparse
@ -25,9 +26,19 @@ from pathlib import Path
MAX_CONFIG = 16 * 1024
MAX_REPORT = 64 * 1024
MAX_DIAGNOSTIC = 1024
PHASES = {"requested", "waiting_android_publication", "running",
"local_import_committed", "published", "waiting_acknowledgement",
"peer_consumed", "completed", "failed", "interrupted", "explicitly_degraded"}
PHASES = {
"requested",
"waiting_android_publication",
"running",
"local_import_committed",
"published",
"waiting_acknowledgement",
"peer_consumed",
"completed",
"failed",
"interrupted",
"explicitly_degraded",
}
class OrchestratorInterrupted(RuntimeError):
@ -40,8 +51,10 @@ _owned_process_group: int | None = None
def _forward_termination(signum: int, _frame: object) -> None:
"""Forward shutdown only to the exactly-owned helper process group."""
if _owned_process_group is not None:
try: os.killpg(_owned_process_group, signal.SIGTERM)
except ProcessLookupError: pass
try:
os.killpg(_owned_process_group, signal.SIGTERM)
except ProcessLookupError:
pass
raise OrchestratorInterrupted(f"orchestrator received signal {signum}")
@ -54,8 +67,9 @@ def _child_setup() -> None:
def canonical(value: object) -> bytes:
return (json.dumps(value, ensure_ascii=False, sort_keys=True,
separators=(",", ":")) + "\n").encode()
return (
json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + "\n"
).encode()
def atomic_write(path: Path, value: dict) -> None:
@ -67,97 +81,176 @@ def atomic_write(path: Path, value: dict) -> None:
try:
os.fchmod(fd, 0o600)
with os.fdopen(fd, "wb") as stream:
stream.write(raw); stream.flush(); os.fsync(stream.fileno())
stream.write(raw)
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary, path)
directory = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY)
try: os.fsync(directory)
finally: os.close(directory)
try:
os.fsync(directory)
finally:
os.close(directory)
finally:
try: os.unlink(temporary)
except FileNotFoundError: pass
try:
os.unlink(temporary)
except FileNotFoundError:
pass
def load_json(path: Path, limit: int) -> dict:
with path.open("rb") as stream:
raw = stream.read(limit + 1)
if len(raw) > limit: raise RuntimeError(f"{path.name} exceeds its bound")
if len(raw) > limit:
raise RuntimeError(f"{path.name} exceeds its bound")
value = json.loads(raw.decode("utf-8", "strict"))
if not isinstance(value, dict): raise RuntimeError(f"{path.name} must contain an object")
if not isinstance(value, dict):
raise RuntimeError(f"{path.name} must contain an object")
return value
def update(state_path: Path, state: dict, phase: str, **fields: object) -> None:
if phase not in PHASES: raise RuntimeError("invalid run phase")
state.update(fields); state["phase"] = phase
if phase not in PHASES:
raise RuntimeError("invalid run phase")
state.update(fields)
state["phase"] = phase
state["progress_revision"] = int(state.get("progress_revision", 0)) + 1
state["updated_at"] = datetime.now(timezone.utc).isoformat()
atomic_write(state_path, state)
def validate_result(value: dict, run_id: str) -> dict:
required = {"format", "version", "run_id", "producer_peer_id", "consumer_peer_id",
"inbound_generation_id", "outbound_generation_id", "manifest_sha256",
"result", "sessions_reconciled", "domains", "drafts", "ai_midpoint", "ai_post_sync"}
if set(value) != required or value.get("format") != "trainlog-sync-worker-report" or value.get("version") != 1:
required = {
"format",
"version",
"run_id",
"producer_peer_id",
"consumer_peer_id",
"inbound_generation_id",
"outbound_generation_id",
"manifest_sha256",
"result",
"sessions_reconciled",
"domains",
"drafts",
"ai_midpoint",
"ai_post_sync",
}
if (
set(value) != required
or value.get("format") != "trainlog-sync-worker-report"
or value.get("version") != 1
):
raise RuntimeError("malformed peer worker report")
if value.get("run_id") != run_id or value.get("result") != "completed":
raise RuntimeError("peer worker did not complete the correlated run")
for identity, prefix in ((value["producer_peer_id"], "peer_"),
(value["consumer_peer_id"], "peer_"),
(value["inbound_generation_id"], "gen_"),
(value["outbound_generation_id"], "gen_")):
if not isinstance(identity, str) or not re.fullmatch(re.escape(prefix)+r"[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}",identity):
for identity, prefix in (
(value["producer_peer_id"], "peer_"),
(value["consumer_peer_id"], "peer_"),
(value["inbound_generation_id"], "gen_"),
(value["outbound_generation_id"], "gen_"),
):
if not isinstance(identity, str) or not re.fullmatch(
re.escape(prefix)
+ r"[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}",
identity,
):
raise RuntimeError("invalid worker identity")
if type(value["sessions_reconciled"]) is not int or value["sessions_reconciled"] < 0:
raise RuntimeError("invalid sessions_reconciled")
if not isinstance(value["domains"], dict) or not all(isinstance(k, str) and isinstance(v, bool) for k, v in value["domains"].items()):
if not isinstance(value["domains"], dict) or not all(
isinstance(k, str) and isinstance(v, bool) for k, v in value["domains"].items()
):
raise RuntimeError("invalid domain coverage")
if not isinstance(value["drafts"], list) or len(value["drafts"]) > 32:
raise RuntimeError("invalid draft summary")
return value
def read_worker(child: subprocess.Popen[bytes], state_path: Path, state: dict,
run_id: str, timeout: int) -> tuple[dict, bytes]:
def read_worker(
child: subprocess.Popen[bytes], state_path: Path, state: dict, run_id: str, timeout: int
) -> tuple[dict, bytes]:
selector = selectors.DefaultSelector()
assert child.stdout is not None and child.stderr is not None
selector.register(child.stdout, selectors.EVENT_READ, "stdout")
selector.register(child.stderr, selectors.EVENT_READ, "stderr")
buffers = {"stdout": bytearray(), "stderr": bytearray()}
line_buffer = bytearray(); final: dict | None = None
line_buffer = bytearray()
final: dict | None = None
deadline = time.monotonic() + timeout
allowed = ["waiting_android_publication","running","local_import_committed",
"published","waiting_acknowledgement","peer_consumed"]
allowed = [
"waiting_android_publication",
"running",
"local_import_committed",
"published",
"waiting_acknowledgement",
"peer_consumed",
]
last_index = -1
while selector.get_map():
remaining = deadline - time.monotonic()
if remaining <= 0: raise RuntimeError("peer worker timed out")
for key, _ in selector.select(min(remaining, .25)):
if remaining <= 0:
raise RuntimeError("peer worker timed out")
for key, _ in selector.select(min(remaining, 0.25)):
chunk = os.read(key.fileobj.fileno(), 4096)
if not chunk: selector.unregister(key.fileobj); continue
if not chunk:
selector.unregister(key.fileobj)
continue
target = buffers[key.data]
if len(target) + len(chunk) > MAX_REPORT: raise RuntimeError(f"peer worker {key.data} exceeds 64 KiB")
if len(target) + len(chunk) > MAX_REPORT:
raise RuntimeError(f"peer worker {key.data} exceeds 64 KiB")
target.extend(chunk)
if key.data == "stdout":
line_buffer.extend(chunk)
while b"\n" in line_buffer:
raw, _, rest = line_buffer.partition(b"\n"); line_buffer[:] = rest
if len(raw) > 8192: raise RuntimeError("progress frame exceeds 8 KiB")
raw, _, rest = line_buffer.partition(b"\n")
line_buffer[:] = rest
if len(raw) > 8192:
raise RuntimeError("progress frame exceeds 8 KiB")
value = json.loads(raw.decode("utf-8", "strict"))
if value.get("format") == "trainlog-sync-progress":
if set(value)-{"format","version","run_id","phase","producer_peer_id","consumer_peer_id","inbound_generation_id","outbound_generation_id","manifest_sha256"} or value.get("version") != 1 or value.get("run_id") != run_id or value.get("phase") not in allowed:
if (
set(value)
- {
"format",
"version",
"run_id",
"phase",
"producer_peer_id",
"consumer_peer_id",
"inbound_generation_id",
"outbound_generation_id",
"manifest_sha256",
}
or value.get("version") != 1
or value.get("run_id") != run_id
or value.get("phase") not in allowed
):
raise RuntimeError("invalid progress frame")
index=allowed.index(value["phase"])
if index < last_index: raise RuntimeError("progress phase regressed")
last_index=index;update(state_path,state,value["phase"],**{k:v for k,v in value.items() if k not in {"format","version","run_id","phase"}})
else: final=value
index = allowed.index(value["phase"])
if index < last_index:
raise RuntimeError("progress phase regressed")
last_index = index
update(
state_path,
state,
value["phase"],
**{
k: v
for k, v in value.items()
if k not in {"format", "version", "run_id", "phase"}
},
)
else:
final = value
child.wait()
if line_buffer.strip(): raise RuntimeError("unterminated worker frame")
if line_buffer.strip():
raise RuntimeError("unterminated worker frame")
if child.returncode != 0:
detail=bytes(buffers["stderr"]).decode("utf-8","replace").strip()[:MAX_DIAGNOSTIC]
detail = bytes(buffers["stderr"]).decode("utf-8", "replace").strip()[:MAX_DIAGNOSTIC]
raise RuntimeError(f"peer worker exited {child.returncode}: {detail}")
if final is None: raise RuntimeError("peer worker omitted final report")
return validate_result(final,run_id), bytes(buffers["stderr"])
if final is None:
raise RuntimeError("peer worker omitted final report")
return validate_result(final, run_id), bytes(buffers["stderr"])
def run(args: argparse.Namespace) -> int:
@ -167,15 +260,33 @@ def run(args: argparse.Namespace) -> int:
config = load_json(config_path, MAX_CONFIG)
if state.get("run_id") != args.run_id or state.get("request_id") != args.request_id:
raise RuntimeError("admission identity mismatch")
if set(config) != {"format", "version", "enabled", "mode", "expected_peer_id", "transport_root", "owned_root", "timeout_seconds"}:
if set(config) != {
"format",
"version",
"enabled",
"mode",
"expected_peer_id",
"transport_root",
"owned_root",
"timeout_seconds",
}:
raise RuntimeError("invalid trusted sync configuration")
if config["format"] != "trainlog-sync-orchestrator-config" or config["version"] != 1 or config["enabled"] is not True:
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"] 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")
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")
timeout = config["timeout_seconds"]
if not isinstance(timeout, int) or timeout < 1 or timeout > 900:
raise RuntimeError("invalid worker timeout")
@ -184,39 +295,96 @@ def run(args: argparse.Namespace) -> int:
lock_path = Path(args.database).parent / "sync.lock"
lock_fd = os.open(lock_path, os.O_RDWR | os.O_CREAT, 0o600)
try:
try: fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
try:
fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
update(state_path, state, "failed", result="conflict", diagnostic="Another synchronization owns the common lock", finished_at=datetime.now(timezone.utc).isoformat())
update(
state_path,
state,
"failed",
result="conflict",
diagnostic="Another synchronization owns the common lock",
finished_at=datetime.now(timezone.utc).isoformat(),
)
return 4
update(state_path, state, "running", result="running", diagnostic="")
environment = os.environ.copy()
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),"--mode",config["mode"]]
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),
"--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"))
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)
child = subprocess.Popen(
command,
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
env=environment,
preexec_fn=_child_setup,
)
_owned_process_group = child.pid
try: result,_ = read_worker(child,state_path,state,args.run_id,timeout)
try:
result, _ = read_worker(child, state_path, state, args.run_id, timeout)
except Exception:
if child.poll() is None:
os.killpg(child.pid,signal.SIGTERM)
try: child.wait(5)
except subprocess.TimeoutExpired: os.killpg(child.pid,signal.SIGKILL);child.wait()
os.killpg(child.pid, signal.SIGTERM)
try:
child.wait(5)
except subprocess.TimeoutExpired:
os.killpg(child.pid, signal.SIGKILL)
child.wait()
raise
finally:
_owned_process_group = None
update(state_path, state, "completed", result="completed", diagnostic="",
inbound_generation_id=result["inbound_generation_id"],outbound_generation_id=result["outbound_generation_id"],producer_peer_id=result["producer_peer_id"],consumer_peer_id=result["consumer_peer_id"],sessions_reconciled=result["sessions_reconciled"],domains=result["domains"],drafts=result["drafts"],manifest_sha256=result["manifest_sha256"],
ai_midpoint=result["ai_midpoint"], ai_post_sync=result["ai_post_sync"],
finished_at=datetime.now(timezone.utc).isoformat())
update(
state_path,
state,
"completed",
result="completed",
diagnostic="",
inbound_generation_id=result["inbound_generation_id"],
outbound_generation_id=result["outbound_generation_id"],
producer_peer_id=result["producer_peer_id"],
consumer_peer_id=result["consumer_peer_id"],
sessions_reconciled=result["sessions_reconciled"],
domains=result["domains"],
drafts=result["drafts"],
manifest_sha256=result["manifest_sha256"],
ai_midpoint=result["ai_midpoint"],
ai_post_sync=result["ai_post_sync"],
finished_at=datetime.now(timezone.utc).isoformat(),
)
return 0
finally:
os.close(lock_fd)
@ -224,21 +392,39 @@ def run(args: argparse.Namespace) -> int:
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--database", required=True); parser.add_argument("--state", required=True)
parser.add_argument("--config", required=True); parser.add_argument("--run-id", required=True)
parser.add_argument("--database", required=True)
parser.add_argument("--state", required=True)
parser.add_argument("--config", required=True)
parser.add_argument("--run-id", required=True)
parser.add_argument("--request-id", required=True)
args = parser.parse_args()
signal.signal(signal.SIGTERM, _forward_termination)
signal.signal(signal.SIGINT, _forward_termination)
try: return run(args)
except (OSError, ValueError, json.JSONDecodeError, RuntimeError, subprocess.TimeoutExpired) as error:
try:
return run(args)
except (
OSError,
ValueError,
json.JSONDecodeError,
RuntimeError,
subprocess.TimeoutExpired,
) as error:
try:
path = Path(args.state); state = load_json(path, MAX_REPORT)
path = Path(args.state)
state = load_json(path, MAX_REPORT)
phase = "interrupted" if isinstance(error, OrchestratorInterrupted) else "failed"
update(path, state, phase, result=phase, diagnostic=str(error)[:MAX_DIAGNOSTIC],
finished_at=datetime.now(timezone.utc).isoformat())
except Exception: pass
update(
path,
state,
phase,
result=phase,
diagnostic=str(error)[:MAX_DIAGNOSTIC],
finished_at=datetime.now(timezone.utc).isoformat(),
)
except Exception:
pass
return 2
if __name__ == "__main__": raise SystemExit(main())
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -5,6 +5,7 @@ The directory is the object-level seam implemented by the MTP adapter in a
deployed bundle and directly supplied by isolated tests. Business documents are
always produced and consumed by the existing generation services.
"""
from __future__ import annotations
import argparse, hashlib, json, os, sys, time, uuid
from contextlib import closing, redirect_stdout
@ -13,107 +14,266 @@ from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import sync_generation_exchange as generation
CAPS={"generation-manifest-v1","generation-ack-v1","causal-delete-v1","mobile-history-v4","execution-draft-v1"}
CAPS = {
"generation-manifest-v1",
"generation-ack-v1",
"causal-delete-v1",
"mobile-history-v4",
"execution-draft-v1",
}
def emit(run_id:str,phase:str,**values:object)->None:
value={"format":"trainlog-sync-progress","version":1,"run_id":run_id,"phase":phase}|values
print(json.dumps(value,sort_keys=True,separators=(",",":")),flush=True)
def bounded(path:Path,limit:int=65536)->bytes:
def emit(run_id: str, phase: str, **values: object) -> None:
value = {
"format": "trainlog-sync-progress",
"version": 1,
"run_id": run_id,
"phase": phase,
} | values
print(json.dumps(value, sort_keys=True, separators=(",", ":")), flush=True)
def bounded(path: Path, limit: int = 65536) -> bytes:
with path.open("rb") as stream:
raw=stream.read(limit+1)
if len(raw)>limit: raise RuntimeError(f"{path.name} exceeds bound")
raw = stream.read(limit + 1)
if len(raw) > limit:
raise RuntimeError(f"{path.name} exceeds bound")
return raw
def publish_json(path:Path,value:dict)->None:
"""Publish a coordination object only after all bytes are durable."""
temporary=path.with_name("."+path.name+".tmp-"+str(os.getpid()))
with temporary.open("wb") as stream:
stream.write(generation.canonical(value));stream.flush();os.fsync(stream.fileno())
os.chmod(temporary,0o600);os.replace(temporary,path)
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
def publish_json(path: Path, value: dict) -> None:
"""Publish a coordination object only after all bytes are durable."""
temporary = path.with_name("." + path.name + ".tmp-" + str(os.getpid()))
with temporary.open("wb") as stream:
stream.write(generation.canonical(value))
stream.flush()
os.fsync(stream.fileno())
os.chmod(temporary, 0o600)
os.replace(temporary, path)
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:
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)
if time.monotonic() >= deadline:
raise RuntimeError(f"timeout waiting for {path.name}")
if pump is not None:
pump()
time.sleep(0.05)
def wait_json(path:Path,deadline:float,predicate,pump=None)->dict:
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)
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(0.05)
def load_peer(root:Path,expected:str)->dict:
value=json.loads(bounded(root/"android-peer-v1.json"))
if set(value)!={"format","version","peer_id","capabilities"} or value["format"]!="trainlog-sync-peer" or type(value["version"]) is not int or value["version"]!=1 or value["peer_id"]!=expected or not isinstance(value["capabilities"],list) or not CAPS.issubset(set(value["capabilities"])):
def load_peer(root: Path, expected: str) -> dict:
value = json.loads(bounded(root / "android-peer-v1.json"))
if (
set(value) != {"format", "version", "peer_id", "capabilities"}
or value["format"] != "trainlog-sync-peer"
or type(value["version"]) is not int
or value["version"] != 1
or value["peer_id"] != expected
or not isinstance(value["capabilities"], list)
or not CAPS.issubset(set(value["capabilities"]))
):
raise RuntimeError("incompatible or unpaired Android peer advertisement")
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.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)
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.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)
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"
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())
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"
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())
# Export codecs retain human diagnostics on stdout when invoked as CLIs.
# The worker stdout contract is typed NDJSON only, so route those bounded
# diagnostics to the separately bounded stderr channel.
with redirect_stdout(sys.stderr):
stage,manifest,digest=generation.capture_desktop(args.database,args.owned_root,peer["peer_id"],args.run_id,outgoing_id)
stage, manifest, digest = generation.capture_desktop(
args.database, args.owned_root, peer["peer_id"], args.run_id, outgoing_id
)
del stage
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"
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','')}")
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"
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:
inbound_row=db.execute("SELECT result FROM sync_consumed_generations WHERE generation_id=?",(ack["generation_id"],)).fetchone()
outbound_row=db.execute("SELECT status FROM sync_generations WHERE generation_id=?",(outgoing_id,)).fetchone()
drafts=[{"draft_id":row[0],"state":row[1],"session_type":json.loads(row[2]).get("session_type","training"),"occurrence_count":len(json.loads(row[2]).get("exercises",[]))} for row in db.execute("SELECT session_id,state,payload_json FROM execution_drafts ORDER BY session_id LIMIT 32")]
if inbound_row!=("consumed",) or outbound_row!=("acknowledged",): raise RuntimeError("generation evidence did not corroborate completion")
emit(args.run_id,"peer_consumed",outbound_generation_id=outgoing_id)
report={"format":"trainlog-sync-worker-report","version":1,"run_id":args.run_id,"producer_peer_id":peer["peer_id"],"consumer_peer_id":desktop_peer,"inbound_generation_id":ack["generation_id"],"outbound_generation_id":outgoing_id,"manifest_sha256":digest,"result":"completed","sessions_reconciled":0,"domains":{name:True for name in ("history-v4","execution-draft-v1","causal-delete-v1","equipment","aliases","profiles","body-zones","feedback")},"drafts":drafts,"ai_midpoint":{"result":"not_configured"},"ai_post_sync":{"result":"not_configured"}}
print(json.dumps(report,sort_keys=True,separators=(",",":")),flush=True);return 0
inbound_row = db.execute(
"SELECT result FROM sync_consumed_generations WHERE generation_id=?",
(ack["generation_id"],),
).fetchone()
outbound_row = db.execute(
"SELECT status FROM sync_generations WHERE generation_id=?", (outgoing_id,)
).fetchone()
drafts = [
{
"draft_id": row[0],
"state": row[1],
"session_type": json.loads(row[2]).get("session_type", "training"),
"occurrence_count": len(json.loads(row[2]).get("exercises", [])),
}
for row in db.execute(
"SELECT session_id,state,payload_json FROM execution_drafts ORDER BY session_id LIMIT 32"
)
]
if inbound_row != ("consumed",) or outbound_row != ("acknowledged",):
raise RuntimeError("generation evidence did not corroborate completion")
emit(args.run_id, "peer_consumed", outbound_generation_id=outgoing_id)
report = {
"format": "trainlog-sync-worker-report",
"version": 1,
"run_id": args.run_id,
"producer_peer_id": peer["peer_id"],
"consumer_peer_id": desktop_peer,
"inbound_generation_id": ack["generation_id"],
"outbound_generation_id": outgoing_id,
"manifest_sha256": digest,
"result": "completed",
"sessions_reconciled": 0,
"domains": {
name: True
for name in (
"history-v4",
"execution-draft-v1",
"causal-delete-v1",
"equipment",
"aliases",
"profiles",
"body-zones",
"feedback",
)
},
"drafts": drafts,
"ai_midpoint": {"result": "not_configured"},
"ai_post_sync": {"result": "not_configured"},
}
print(json.dumps(report, sort_keys=True, separators=(",", ":")), flush=True)
return 0
if __name__=="__main__": raise SystemExit(main())
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -17,19 +17,28 @@
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 (*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 (*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);
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 char *expected_peer_id,
const char *local_root,
char *diagnostic,
size_t diagnostic_size);
const TrainlogGenerationMtpIo *trainlog_generation_mtp_production_io(void);
#endif

View file

@ -27,109 +27,121 @@ static const TileContract contracts[TRAINLOG_DASHBOARD_LAYOUT_TILE_COUNT] = {
{"last-session", 3U, 7U, 3U, 6U},
{"max-records", 3U, 8U, 3U, 7U},
{"muscle-distribution", 5U, 12U, 4U, 9U},
{"cardio-recovery", 5U, 12U, 4U, 8U}
};
{"cardio-recovery", 5U, 12U, 4U, 8U}};
static const TrainlogDashboardTileLayout defaults[TRAINLOG_DASHBOARD_LAYOUT_TILE_COUNT] = {
{"next-session", 0U, 0U, 5U, 4U}, {"activity", 5U, 0U, 7U, 4U},
{"progression", 0U, 4U, 8U, 5U}, {"last-session", 8U, 4U, 4U, 3U},
{"next-session", 0U, 0U, 5U, 4U},
{"activity", 5U, 0U, 7U, 4U},
{"progression", 0U, 4U, 8U, 5U},
{"last-session", 8U, 4U, 4U, 3U},
{"max-records", 8U, 7U, 4U, 3U},
{"muscle-distribution", 0U, 9U, 6U, 5U},
{"cardio-recovery", 6U, 10U, 6U, 4U}
};
{"cardio-recovery", 6U, 10U, 6U, 4U}};
static int contract_index(const char *id)
{
static int contract_index(const char *id) {
size_t index;
for (index = 0U; index < TRAINLOG_DASHBOARD_LAYOUT_TILE_COUNT; ++index)
if (strcmp(id, contracts[index].id) == 0) return (int)index;
for (index = 0U; index < TRAINLOG_DASHBOARD_LAYOUT_TILE_COUNT; ++index) {
if (strcmp(id, contracts[index].id) == 0) {
return (int)index;
}
}
return -1;
}
void trainlog_dashboard_layout_default(TrainlogDashboardLayout *layout)
{
if (layout == NULL) return;
void trainlog_dashboard_layout_default(TrainlogDashboardLayout *layout) {
if (layout == NULL) {
return;
}
layout->revision = 0U;
(void)memcpy(layout->tiles, defaults, sizeof(defaults));
}
static bool overlap(const TrainlogDashboardTileLayout *a,
const TrainlogDashboardTileLayout *b)
{
return a->x < b->x + b->width && a->x + a->width > b->x &&
a->y < b->y + b->height && a->y + a->height > b->y;
static bool overlap(const TrainlogDashboardTileLayout *a, const TrainlogDashboardTileLayout *b) {
return a->x < b->x + b->width && a->x + a->width > b->x && a->y < b->y + b->height &&
a->y + a->height > b->y;
}
bool trainlog_dashboard_layout_validate(const TrainlogDashboardLayout *layout)
{
bool trainlog_dashboard_layout_validate(const TrainlogDashboardLayout *layout) {
bool seen[TRAINLOG_DASHBOARD_LAYOUT_TILE_COUNT] = {false};
size_t index;
if (layout == NULL || layout->revision > TRAINLOG_DASHBOARD_LAYOUT_MAX_REVISION)
if (layout == NULL || layout->revision > TRAINLOG_DASHBOARD_LAYOUT_MAX_REVISION) {
return false;
}
for (index = 0U; index < TRAINLOG_DASHBOARD_LAYOUT_TILE_COUNT; ++index) {
const TrainlogDashboardTileLayout *tile = &layout->tiles[index];
int found = contract_index(tile->id);
const TileContract *contract;
size_t other;
if (found < 0 || seen[(size_t)found]) return false;
if (found < 0 || seen[(size_t)found]) {
return false;
}
seen[(size_t)found] = true;
contract = &contracts[(size_t)found];
if (tile->y > TRAINLOG_DASHBOARD_LAYOUT_MAX_Y ||
tile->width < contract->min_width || tile->width > contract->max_width ||
tile->height < contract->min_height || tile->height > contract->max_height ||
tile->x + tile->width > TRAINLOG_DASHBOARD_LAYOUT_COLUMNS)
if (tile->y > TRAINLOG_DASHBOARD_LAYOUT_MAX_Y || tile->width < contract->min_width ||
tile->width > contract->max_width || tile->height < contract->min_height ||
tile->height > contract->max_height ||
tile->x + tile->width > TRAINLOG_DASHBOARD_LAYOUT_COLUMNS) {
return false;
for (other = index + 1U; other < TRAINLOG_DASHBOARD_LAYOUT_TILE_COUNT; ++other)
if (overlap(tile, &layout->tiles[other])) return false;
}
for (other = index + 1U; other < TRAINLOG_DASHBOARD_LAYOUT_TILE_COUNT; ++other) {
if (overlap(tile, &layout->tiles[other])) {
return false;
}
}
}
return true;
}
static bool exact_object(const yyjson_val *value, size_t expected)
{
static bool exact_object(const yyjson_val *value, size_t expected) {
return yyjson_is_obj(value) && yyjson_obj_size(value) == expected;
}
static bool read_u16(yyjson_val *value, uint16_t *output)
{
static bool read_u16(yyjson_val *value, uint16_t *output) {
uint64_t number;
if (!yyjson_is_uint(value)) return false;
if (!yyjson_is_uint(value)) {
return false;
}
number = yyjson_get_uint(value);
if (number > UINT16_MAX) return false;
if (number > UINT16_MAX) {
return false;
}
*output = (uint16_t)number;
return true;
}
static bool equals_uint(yyjson_val *value, uint64_t expected)
{
static bool equals_uint(yyjson_val *value, uint64_t expected) {
return yyjson_is_uint(value) && yyjson_get_uint(value) == expected;
}
TrainlogDashboardLayoutResult trainlog_dashboard_layout_parse(
const char *json, size_t size, TrainlogDashboardLayout *layout)
{
TrainlogDashboardLayoutResult
trainlog_dashboard_layout_parse(const char *json, size_t size, TrainlogDashboardLayout *layout) {
yyjson_read_err error;
yyjson_doc *document;
yyjson_val *root;
yyjson_val *tiles;
size_t index;
if (json == NULL || layout == NULL || size == 0U ||
size >= TRAINLOG_DASHBOARD_LAYOUT_JSON_CAPACITY)
size >= TRAINLOG_DASHBOARD_LAYOUT_JSON_CAPACITY) {
return TRAINLOG_DASHBOARD_LAYOUT_INVALID;
}
document = yyjson_read_opts((char *)json, size, YYJSON_READ_NOFLAG, NULL, &error);
if (document == NULL) return TRAINLOG_DASHBOARD_LAYOUT_INVALID;
if (document == NULL) {
return TRAINLOG_DASHBOARD_LAYOUT_INVALID;
}
root = yyjson_doc_get_root(document);
if (!exact_object(root, 5U) ||
!yyjson_equals_str(yyjson_obj_get(root, "format"), "trainlog-dashboard-layout") ||
!equals_uint(yyjson_obj_get(root, "version"), 1U) ||
!equals_uint(yyjson_obj_get(root, "columns"), 12U) ||
!yyjson_is_uint(yyjson_obj_get(root, "revision"))) {
yyjson_doc_free(document); return TRAINLOG_DASHBOARD_LAYOUT_INVALID;
yyjson_doc_free(document);
return TRAINLOG_DASHBOARD_LAYOUT_INVALID;
}
layout->revision = yyjson_get_uint(yyjson_obj_get(root, "revision"));
tiles = yyjson_obj_get(root, "tiles");
if (!yyjson_is_arr(tiles) || yyjson_arr_size(tiles) != TRAINLOG_DASHBOARD_LAYOUT_TILE_COUNT) {
yyjson_doc_free(document); return TRAINLOG_DASHBOARD_LAYOUT_INVALID;
yyjson_doc_free(document);
return TRAINLOG_DASHBOARD_LAYOUT_INVALID;
}
for (index = 0U; index < TRAINLOG_DASHBOARD_LAYOUT_TILE_COUNT; ++index) {
yyjson_val *tile = yyjson_arr_get(tiles, index);
@ -141,116 +153,184 @@ TrainlogDashboardLayoutResult trainlog_dashboard_layout_parse(
!read_u16(yyjson_obj_get(tile, "y"), &layout->tiles[index].y) ||
!read_u16(yyjson_obj_get(tile, "width"), &layout->tiles[index].width) ||
!read_u16(yyjson_obj_get(tile, "height"), &layout->tiles[index].height)) {
yyjson_doc_free(document); return TRAINLOG_DASHBOARD_LAYOUT_INVALID;
yyjson_doc_free(document);
return TRAINLOG_DASHBOARD_LAYOUT_INVALID;
}
text = yyjson_get_str(id); length = yyjson_get_len(id);
text = yyjson_get_str(id);
length = yyjson_get_len(id);
if (length == 0U || length >= sizeof(layout->tiles[index].id)) {
yyjson_doc_free(document); return TRAINLOG_DASHBOARD_LAYOUT_INVALID;
yyjson_doc_free(document);
return TRAINLOG_DASHBOARD_LAYOUT_INVALID;
}
(void)memcpy(layout->tiles[index].id, text, length);
layout->tiles[index].id[length] = '\0';
}
yyjson_doc_free(document);
return trainlog_dashboard_layout_validate(layout)
? TRAINLOG_DASHBOARD_LAYOUT_OK : TRAINLOG_DASHBOARD_LAYOUT_INVALID;
return trainlog_dashboard_layout_validate(layout) ? TRAINLOG_DASHBOARD_LAYOUT_OK
: TRAINLOG_DASHBOARD_LAYOUT_INVALID;
}
bool trainlog_dashboard_layout_path(char *output, size_t capacity)
{
bool trainlog_dashboard_layout_path(char *output, size_t capacity) {
const char *base = getenv("XDG_CONFIG_HOME");
const char *home;
int length;
if (output == NULL || capacity == 0U) return false;
if (base != NULL && base[0] != '\0')
if (output == NULL || capacity == 0U) {
return false;
}
if (base != NULL && base[0] != '\0') {
length = snprintf(output, capacity, "%s/trainlog/web/dashboard-layout-v1.json", base);
else {
} else {
home = getenv("HOME");
if (home == NULL || home[0] == '\0') return false;
length = snprintf(output, capacity, "%s/.config/trainlog/web/dashboard-layout-v1.json", home);
if (home == NULL || home[0] == '\0') {
return false;
}
length =
snprintf(output, capacity, "%s/.config/trainlog/web/dashboard-layout-v1.json", home);
}
return length > 0 && (size_t)length < capacity;
}
static bool ensure_private_directories(const char *path)
{
static bool ensure_private_directories(const char *path) {
char web[4096];
char trainlog[4096];
char *last;
struct stat status;
if (path == NULL || strlen(path) >= sizeof(web)) return false;
if (path == NULL || strlen(path) >= sizeof(web)) {
return false;
}
(void)snprintf(web, sizeof(web), "%s", path);
last = strrchr(web, '/'); if (last == NULL) return false; *last = '\0';
last = strrchr(web, '/');
if (last == NULL) {
return false;
}
*last = '\0';
(void)snprintf(trainlog, sizeof(trainlog), "%s", web);
last = strrchr(trainlog, '/'); if (last == NULL) return false; *last = '\0';
last = strrchr(trainlog, '/');
if (last == NULL) {
return false;
}
*last = '\0';
/* CONTRACT: only the two Trainlog-owned directories are created/chmodded;
* an existing symlink or non-directory is never followed. */
{
if (mkdir(trainlog, 0700) != 0 && errno != EEXIST) return false;
if (lstat(trainlog, &status) != 0 || !S_ISDIR(status.st_mode)) return false;
if (chmod(trainlog, 0700) != 0) return false;
if (mkdir(trainlog, 0700) != 0 && errno != EEXIST) {
return false;
}
if (lstat(trainlog, &status) != 0 || !S_ISDIR(status.st_mode)) {
return false;
}
if (chmod(trainlog, 0700) != 0) {
return false;
}
}
if (mkdir(web, 0700) != 0 && errno != EEXIST) {
return false;
}
if (lstat(web, &status) != 0 || !S_ISDIR(status.st_mode)) {
return false;
}
if (mkdir(web, 0700) != 0 && errno != EEXIST) return false;
if (lstat(web, &status) != 0 || !S_ISDIR(status.st_mode)) return false;
return chmod(web, 0700) == 0;
}
static bool sync_parent(const char *path)
{
char parent[4096]; char *slash; int fd; bool ok;
if (strlen(path) >= sizeof(parent)) return false;
static bool sync_parent(const char *path) {
char parent[4096];
char *slash;
int fd;
bool ok;
if (strlen(path) >= sizeof(parent)) {
return false;
}
(void)snprintf(parent, sizeof(parent), "%s", path);
slash = strrchr(parent, '/'); if (slash == NULL) return false; *slash = '\0';
slash = strrchr(parent, '/');
if (slash == NULL) {
return false;
}
*slash = '\0';
fd = open(parent, O_RDONLY | O_DIRECTORY | O_CLOEXEC | O_NOFOLLOW);
if (fd < 0) return false;
ok = fsync(fd) == 0; if (close(fd) != 0) ok = false; return ok;
if (fd < 0) {
return false;
}
ok = fsync(fd) == 0;
if (close(fd) != 0) {
ok = false;
}
return ok;
}
bool trainlog_dashboard_layout_serialize(const TrainlogDashboardLayout *layout,
TrainlogDashboardLayoutSource source, bool include_source,
char *output, size_t capacity, size_t *size)
{
size_t used = 0U; size_t index; int count;
const char *source_name = source == TRAINLOG_DASHBOARD_LAYOUT_PERSISTED
? "persisted" : source == TRAINLOG_DASHBOARD_LAYOUT_INVALID_PERSISTED
? "invalid_persisted" : "default";
if (!trainlog_dashboard_layout_validate(layout) || output == NULL || size == NULL)
TrainlogDashboardLayoutSource source,
bool include_source,
char *output,
size_t capacity,
size_t *size) {
size_t used = 0U;
size_t index;
int count;
const char *source_name = source == TRAINLOG_DASHBOARD_LAYOUT_PERSISTED ? "persisted"
: source == TRAINLOG_DASHBOARD_LAYOUT_INVALID_PERSISTED
? "invalid_persisted"
: "default";
if (!trainlog_dashboard_layout_validate(layout) || output == NULL || size == NULL) {
return false;
#define APPEND(...) do { count = snprintf(output + used, capacity - used, __VA_ARGS__); \
if (count < 0 || (size_t)count >= capacity - used) return false; used += (size_t)count; } while (0)
}
#define APPEND(...) \
do { \
count = snprintf(output + used, capacity - used, __VA_ARGS__); \
if (count < 0 || (size_t)count >= capacity - used) \
return false; \
used += (size_t)count; \
} while (0)
APPEND("{\"format\":\"trainlog-dashboard-layout\",\"version\":1,"
"\"revision\":%llu,\"columns\":12", (unsigned long long)layout->revision);
if (include_source) APPEND(",\"source\":\"%s\"", source_name);
"\"revision\":%llu,\"columns\":12",
(unsigned long long)layout->revision);
if (include_source) {
APPEND(",\"source\":\"%s\"", source_name);
}
APPEND(",\"tiles\":[");
for (index = 0U; index < TRAINLOG_DASHBOARD_LAYOUT_TILE_COUNT; ++index) {
const TrainlogDashboardTileLayout *tile = &layout->tiles[index];
APPEND("%s{\"id\":\"%s\",\"x\":%u,\"y\":%u,\"width\":%u,\"height\":%u}",
index == 0U ? "" : ",", tile->id, tile->x, tile->y,
tile->width, tile->height);
index == 0U ? "" : ",",
tile->id,
tile->x,
tile->y,
tile->width,
tile->height);
}
APPEND("]}\n");
#undef APPEND
*size = used; return true;
*size = used;
return true;
}
TrainlogDashboardLayoutResult trainlog_dashboard_layout_load(
TrainlogDashboardLayout *layout, TrainlogDashboardLayoutSource *source)
{
char path[4096]; char buffer[TRAINLOG_DASHBOARD_LAYOUT_JSON_CAPACITY];
struct stat status; ssize_t count; int fd; TrainlogDashboardLayoutResult result;
if (layout == NULL || source == NULL || !trainlog_dashboard_layout_path(path, sizeof(path)))
TrainlogDashboardLayoutResult
trainlog_dashboard_layout_load(TrainlogDashboardLayout *layout,
TrainlogDashboardLayoutSource *source) {
char path[4096];
char buffer[TRAINLOG_DASHBOARD_LAYOUT_JSON_CAPACITY];
struct stat status;
ssize_t count;
int fd;
TrainlogDashboardLayoutResult result;
if (layout == NULL || source == NULL || !trainlog_dashboard_layout_path(path, sizeof(path))) {
return TRAINLOG_DASHBOARD_LAYOUT_IO_ERROR;
trainlog_dashboard_layout_default(layout); *source = TRAINLOG_DASHBOARD_LAYOUT_DEFAULT;
}
trainlog_dashboard_layout_default(layout);
*source = TRAINLOG_DASHBOARD_LAYOUT_DEFAULT;
fd = open(path, O_RDONLY | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK);
if (fd < 0) {
if (errno == ENOENT || errno == ELOOP) {
if (errno == ELOOP) *source = TRAINLOG_DASHBOARD_LAYOUT_INVALID_PERSISTED;
if (errno == ELOOP) {
*source = TRAINLOG_DASHBOARD_LAYOUT_INVALID_PERSISTED;
}
return TRAINLOG_DASHBOARD_LAYOUT_OK;
}
return TRAINLOG_DASHBOARD_LAYOUT_IO_ERROR;
}
if (fstat(fd, &status) != 0 || !S_ISREG(status.st_mode) || status.st_size <= 0 ||
status.st_size >= (off_t)sizeof(buffer)) {
(void)close(fd); *source = TRAINLOG_DASHBOARD_LAYOUT_INVALID_PERSISTED;
(void)close(fd);
*source = TRAINLOG_DASHBOARD_LAYOUT_INVALID_PERSISTED;
return TRAINLOG_DASHBOARD_LAYOUT_OK;
}
count = read(fd, buffer, (size_t)status.st_size);
@ -262,45 +342,67 @@ TrainlogDashboardLayoutResult trainlog_dashboard_layout_load(
if (result != TRAINLOG_DASHBOARD_LAYOUT_OK) {
trainlog_dashboard_layout_default(layout);
*source = TRAINLOG_DASHBOARD_LAYOUT_INVALID_PERSISTED;
} else *source = TRAINLOG_DASHBOARD_LAYOUT_PERSISTED;
} else {
*source = TRAINLOG_DASHBOARD_LAYOUT_PERSISTED;
}
return TRAINLOG_DASHBOARD_LAYOUT_OK;
}
static bool write_all(int fd, const char *data, size_t size)
{
static bool write_all(int fd, const char *data, size_t size) {
size_t used = 0U;
while (used < size) { ssize_t count = write(fd, data + used, size - used);
if (count < 0 && errno == EINTR) continue; if (count <= 0) return false; used += (size_t)count; }
while (used < size) {
ssize_t count = write(fd, data + used, size - used);
if (count < 0 && errno == EINTR) {
continue;
}
if (count <= 0) {
return false;
}
used += (size_t)count;
}
return true;
}
TrainlogDashboardLayoutResult trainlog_dashboard_layout_save(
const TrainlogDashboardLayout *layout, uint64_t expected_revision,
TrainlogDashboardLayout *saved)
{
TrainlogDashboardLayout current; TrainlogDashboardLayoutSource source;
TrainlogDashboardLayoutResult trainlog_dashboard_layout_save(const TrainlogDashboardLayout *layout,
uint64_t expected_revision,
TrainlogDashboardLayout *saved) {
TrainlogDashboardLayout current;
TrainlogDashboardLayoutSource source;
char path[4096], temporary[4128], json[TRAINLOG_DASHBOARD_LAYOUT_JSON_CAPACITY];
size_t size; int fd; bool ok;
size_t size;
int fd;
bool ok;
if (layout == NULL || saved == NULL || !trainlog_dashboard_layout_validate(layout) ||
layout->revision != expected_revision || expected_revision >= TRAINLOG_DASHBOARD_LAYOUT_MAX_REVISION)
layout->revision != expected_revision ||
expected_revision >= TRAINLOG_DASHBOARD_LAYOUT_MAX_REVISION) {
return TRAINLOG_DASHBOARD_LAYOUT_INVALID;
if (trainlog_dashboard_layout_load(&current, &source) != TRAINLOG_DASHBOARD_LAYOUT_OK)
}
if (trainlog_dashboard_layout_load(&current, &source) != TRAINLOG_DASHBOARD_LAYOUT_OK) {
return TRAINLOG_DASHBOARD_LAYOUT_IO_ERROR;
if (current.revision != expected_revision) return TRAINLOG_DASHBOARD_LAYOUT_CONFLICT;
*saved = *layout; saved->revision = expected_revision + 1U;
if (!trainlog_dashboard_layout_path(path, sizeof(path)) ||
!ensure_private_directories(path) ||
!trainlog_dashboard_layout_serialize(saved, TRAINLOG_DASHBOARD_LAYOUT_PERSISTED,
false, json, sizeof(json), &size) ||
snprintf(temporary, sizeof(temporary), "%s.tmp.XXXXXX", path) >= (int)sizeof(temporary))
}
if (current.revision != expected_revision) {
return TRAINLOG_DASHBOARD_LAYOUT_CONFLICT;
}
*saved = *layout;
saved->revision = expected_revision + 1U;
if (!trainlog_dashboard_layout_path(path, sizeof(path)) || !ensure_private_directories(path) ||
!trainlog_dashboard_layout_serialize(
saved, TRAINLOG_DASHBOARD_LAYOUT_PERSISTED, false, json, sizeof(json), &size) ||
snprintf(temporary, sizeof(temporary), "%s.tmp.XXXXXX", path) >= (int)sizeof(temporary)) {
return TRAINLOG_DASHBOARD_LAYOUT_IO_ERROR;
}
fd = mkstemp(temporary);
if (fd < 0) return TRAINLOG_DASHBOARD_LAYOUT_IO_ERROR;
if (fd < 0) {
return TRAINLOG_DASHBOARD_LAYOUT_IO_ERROR;
}
ok = fchmod(fd, 0600) == 0 && fcntl(fd, F_SETFD, FD_CLOEXEC) == 0 &&
write_all(fd, json, size) && fsync(fd) == 0;
if (close(fd) != 0) ok = false;
write_all(fd, json, size) && fsync(fd) == 0;
if (close(fd) != 0) {
ok = false;
}
if (!ok || rename(temporary, path) != 0) {
(void)unlink(temporary); return TRAINLOG_DASHBOARD_LAYOUT_IO_ERROR;
(void)unlink(temporary);
return TRAINLOG_DASHBOARD_LAYOUT_IO_ERROR;
}
/* INVARIANT: after rename the valid file is committed. Directory fsync is
* attempted for crash durability, but its failure cannot roll back or make
@ -309,16 +411,21 @@ TrainlogDashboardLayoutResult trainlog_dashboard_layout_save(
return TRAINLOG_DASHBOARD_LAYOUT_OK;
}
TrainlogDashboardLayoutResult trainlog_dashboard_layout_delete(uint64_t expected_revision)
{
TrainlogDashboardLayout current; TrainlogDashboardLayoutSource source; char path[4096];
if (trainlog_dashboard_layout_load(&current, &source) != TRAINLOG_DASHBOARD_LAYOUT_OK)
TrainlogDashboardLayoutResult trainlog_dashboard_layout_delete(uint64_t expected_revision) {
TrainlogDashboardLayout current;
TrainlogDashboardLayoutSource source;
char path[4096];
if (trainlog_dashboard_layout_load(&current, &source) != TRAINLOG_DASHBOARD_LAYOUT_OK) {
return TRAINLOG_DASHBOARD_LAYOUT_IO_ERROR;
if (current.revision != expected_revision) return TRAINLOG_DASHBOARD_LAYOUT_CONFLICT;
if (!trainlog_dashboard_layout_path(path, sizeof(path))) return TRAINLOG_DASHBOARD_LAYOUT_IO_ERROR;
}
if (current.revision != expected_revision) {
return TRAINLOG_DASHBOARD_LAYOUT_CONFLICT;
}
if (!trainlog_dashboard_layout_path(path, sizeof(path))) {
return TRAINLOG_DASHBOARD_LAYOUT_IO_ERROR;
}
if (unlink(path) != 0) {
return errno == ENOENT ? TRAINLOG_DASHBOARD_LAYOUT_OK
: TRAINLOG_DASHBOARD_LAYOUT_IO_ERROR;
return errno == ENOENT ? TRAINLOG_DASHBOARD_LAYOUT_OK : TRAINLOG_DASHBOARD_LAYOUT_IO_ERROR;
}
(void)sync_parent(path);
return TRAINLOG_DASHBOARD_LAYOUT_OK;

View file

@ -17,42 +17,56 @@ typedef struct Selection {
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 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 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 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)
{
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;
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;
*result = entries[index];
++matches;
}
}
if (matches > 1U) return TRAINLOG_STATUS_CONFLICT;
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)
{
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;
@ -60,35 +74,51 @@ static bool peer_document_matches(const char *path, const char *expected)
yyjson_val *root, *capabilities, *item;
size_t index, maximum;
bool manifest = false, acknowledgement = false, valid;
if (fd < 0) return false;
if (fd < 0) {
return false;
}
count = read(fd, buffer, sizeof(buffer));
(void)close(fd);
if (count <= 0 || (size_t)count >= sizeof(buffer)) return false;
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;
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);
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;
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)
{
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;
@ -100,202 +130,386 @@ static TrainlogStatus select_peer(const TrainlogGenerationMtpIo *io, const char
}
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;
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;
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};
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;
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;
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;
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; }
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;
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)
{
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;
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;
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 (!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);
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 > 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;
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);
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;
}
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;
static bool same_files(const char *left, const char *right) {
int left_fd = open(left, O_RDONLY | O_CLOEXEC);
int right_fd = open(right, O_RDONLY | O_CLOEXEC);
char left_buffer[8192];
char right_buffer[8192];
ssize_t left_count = 0;
ssize_t right_count;
bool same = true;
if (left_fd < 0 || right_fd < 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;
while (same && (left_count = read(left_fd, left_buffer, sizeof(left_buffer))) > 0) {
right_count = read(right_fd, right_buffer, (size_t)left_count);
if (right_count != left_count ||
memcmp(left_buffer, right_buffer, (size_t)left_count) != 0) {
same = false;
}
}
if (same && (left_count < 0 || read(right_fd, right_buffer, 1U) != 0)) {
same = false;
}
if (left_fd >= 0) {
(void)close(left_fd);
}
if (right_fd >= 0) {
(void)close(right_fd);
}
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;
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; }
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(
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);
(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);
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;
}
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);
(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)
{
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_INVALID_ARGUMENT;
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; 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; }
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; }
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)
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;
} 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);
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;
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);
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
.devices = trainlog_usb_list_mtp_devices,
.storages = trainlog_mtp_list_storages,
.children = trainlog_mtp_list_folder,
.ensure_folder = trainlog_mtp_ensure_folder,
.receive = trainlog_mtp_receive_file,
.send = trainlog_mtp_send_text_file,
.remove = trainlog_mtp_delete_object,
.rename = trainlog_mtp_rename_object,
};
const TrainlogGenerationMtpIo *trainlog_generation_mtp_production_io(void) { return &production; }
const TrainlogGenerationMtpIo *trainlog_generation_mtp_production_io(void) {
return &production;
}

View file

@ -5,33 +5,95 @@
#include <utf8proc.h>
static bool append(TrainlogJsonWriter *w, const char *s, size_t n) {
if (w == NULL || s == NULL || w->failed || n > w->capacity - (w->size < w->capacity ? w->size : w->capacity) - 1U) {
if (w != NULL) w->failed = true; return false;
if (w == NULL || s == NULL || w->failed ||
n > w->capacity - (w->size < w->capacity ? w->size : w->capacity) - 1U) {
if (w != NULL) {
w->failed = true;
}
return false;
}
(void)memcpy(w->data + w->size, s, n); w->size += n; w->data[w->size] = '\0'; return true;
(void)memcpy(w->data + w->size, s, n);
w->size += n;
w->data[w->size] = '\0';
return true;
}
void trainlog_json_init(TrainlogJsonWriter *w, char *data, size_t capacity) {
if (w == NULL) return; w->data=data; w->capacity=capacity; w->size=0U; w->failed=data==NULL||capacity==0U; if (!w->failed) data[0]='\0';
if (w == NULL) {
return;
}
w->data = data;
w->capacity = capacity;
w->size = 0U;
w->failed = data == NULL || capacity == 0U;
if (!w->failed) {
data[0] = '\0';
}
}
bool trainlog_json_raw(TrainlogJsonWriter *w,const char *v){return v!=NULL&&append(w,v,strlen(v));}
bool trainlog_json_string(TrainlogJsonWriter *w,const char *v){
const unsigned char *p=(const unsigned char *)v; char escape[7];
if(v==NULL||!append(w,"\"",1U))return false;
while(*p!='\0'){
bool trainlog_json_raw(TrainlogJsonWriter *w, const char *v) {
return v != NULL && append(w, v, strlen(v));
}
bool trainlog_json_string(TrainlogJsonWriter *w, const char *v) {
const unsigned char *p = (const unsigned char *)v;
char escape[7];
if (v == NULL || !append(w, "\"", 1U)) {
return false;
}
while (*p != '\0') {
utf8proc_int32_t codepoint;
utf8proc_ssize_t sequence=utf8proc_iterate(p,-1,&codepoint);
utf8proc_ssize_t sequence = utf8proc_iterate(p, -1, &codepoint);
/* CONTRACT: valid UTF-8 is preserved byte-for-byte; corrupt storage
* must fail serialization instead of producing invalid JSON. */
(void)codepoint;
if(sequence<=0){w->failed=true;return false;}
if(*p=='\"'||*p=='\\'){escape[0]='\\';escape[1]=(char)*p;if(!append(w,escape,2U))return false;}
else if(*p=='\b'||*p=='\f'||*p=='\n'||*p=='\r'||*p=='\t'){const char codes[]="bfnrt";const char chars[]="\b\f\n\r\t";size_t i;escape[0]='\\';for(i=0U;i<5U&&chars[i]!=(char)*p;++i){}escape[1]=codes[i];if(!append(w,escape,2U))return false;}
else if(*p<0x20U){int n=snprintf(escape,sizeof(escape),"\\u%04x",(unsigned int)*p);if(n!=6||!append(w,escape,6U))return false;}
else if(!append(w,(const char *)p,(size_t)sequence))return false;
p+=(size_t)sequence;
if (sequence <= 0) {
w->failed = true;
return false;
}
if (*p == '\"' || *p == '\\') {
escape[0] = '\\';
escape[1] = (char)*p;
if (!append(w, escape, 2U)) {
return false;
}
} else if (*p == '\b' || *p == '\f' || *p == '\n' || *p == '\r' || *p == '\t') {
const char codes[] = "bfnrt";
const char chars[] = "\b\f\n\r\t";
size_t i;
escape[0] = '\\';
for (i = 0U; i < 5U && chars[i] != (char)*p; ++i) {
}
escape[1] = codes[i];
if (!append(w, escape, 2U)) {
return false;
}
} else if (*p < 0x20U) {
int n = snprintf(escape, sizeof(escape), "\\u%04x", (unsigned int)*p);
if (n != 6 || !append(w, escape, 6U)) {
return false;
}
} else if (!append(w, (const char *)p, (size_t)sequence)) {
return false;
}
p += (size_t)sequence;
}
return append(w,"\"",1U);
return append(w, "\"", 1U);
}
bool trainlog_json_size(TrainlogJsonWriter *w, size_t v) {
char b[32];
int n = snprintf(b, sizeof(b), "%zu", v);
return n > 0 && (size_t)n < sizeof(b) && append(w, b, (size_t)n);
}
bool trainlog_json_i64(TrainlogJsonWriter *w, long long v) {
char b[32];
int n = snprintf(b, sizeof(b), "%lld", v);
return n > 0 && (size_t)n < sizeof(b) && append(w, b, (size_t)n);
}
bool trainlog_json_double(TrainlogJsonWriter *w, double v) {
char b[64];
int n;
if (!isfinite(v)) {
w->failed = true;
return false;
}
n = snprintf(b, sizeof(b), "%.17g", v);
return n > 0 && (size_t)n < sizeof(b) && append(w, b, (size_t)n);
}
bool trainlog_json_size(TrainlogJsonWriter*w,size_t v){char b[32];int n=snprintf(b,sizeof(b),"%zu",v);return n>0&&(size_t)n<sizeof(b)&&append(w,b,(size_t)n);}
bool trainlog_json_i64(TrainlogJsonWriter*w,long long v){char b[32];int n=snprintf(b,sizeof(b),"%lld",v);return n>0&&(size_t)n<sizeof(b)&&append(w,b,(size_t)n);}
bool trainlog_json_double(TrainlogJsonWriter*w,double v){char b[64];int n;if(!isfinite(v)){w->failed=true;return false;}n=snprintf(b,sizeof(b),"%.17g",v);return n>0&&(size_t)n<sizeof(b)&&append(w,b,(size_t)n);}

View file

@ -16,92 +16,410 @@
#define SECONDS_PER_DAY INT64_C(86400)
static bool copy_column(sqlite3_stmt *s, int column, char *out, size_t cap) {
const unsigned char *text; int bytes;
if (sqlite3_column_type(s,column)!=SQLITE_TEXT) return false;
text=sqlite3_column_text(s,column); bytes=sqlite3_column_bytes(s,column);
if(text==NULL||bytes<0||(size_t)bytes>=cap)return false;
(void)memcpy(out,text,(size_t)bytes);out[bytes]='\0';return true;
const unsigned char *text;
int bytes;
if (sqlite3_column_type(s, column) != SQLITE_TEXT) {
return false;
}
text = sqlite3_column_text(s, column);
bytes = sqlite3_column_bytes(s, column);
if (text == NULL || bytes < 0 || (size_t)bytes >= cap) {
return false;
}
(void)memcpy(out, text, (size_t)bytes);
out[bytes] = '\0';
return true;
}
static bool read_size(sqlite3_stmt*s,int c,size_t*out){sqlite3_int64 v;if(sqlite3_column_type(s,c)!=SQLITE_INTEGER)return false;v=sqlite3_column_int64(s,c);if(v<0||(uint64_t)v>(uint64_t)SIZE_MAX)return false;*out=(size_t)v;return true;}
static bool prepare(TrainlogDatabase*d,const char*sql,sqlite3_stmt**s){return sqlite3_prepare_v2(d->connection,sql,-1,s,NULL)==SQLITE_OK;}
static bool format_generated(int64_t now,char out[TRAINLOG_TIMESTAMP_MAX+1U]){
time_t value=(time_t)now;struct tm utc;if((int64_t)value!=now||gmtime_r(&value,&utc)==NULL)return false;
return strftime(out,TRAINLOG_TIMESTAMP_MAX+1U,"%Y-%m-%dT%H:%M:%SZ",&utc)>0U;
static bool read_size(sqlite3_stmt *s, int c, size_t *out) {
sqlite3_int64 v;
if (sqlite3_column_type(s, c) != SQLITE_INTEGER) {
return false;
}
v = sqlite3_column_int64(s, c);
if (v < 0 || (uint64_t)v > (uint64_t)SIZE_MAX) {
return false;
}
*out = (size_t)v;
return true;
}
static bool format_local_date(time_t value,char out[11]){struct tm local;return localtime_r(&value,&local)!=NULL&&strftime(out,11U,"%Y-%m-%d",&local)==10U;}
static bool load_user(char out[TRAINLOG_NAME_MAX+1U]){
struct passwd value;struct passwd *result=NULL;char buffer[4096];int rc=getpwuid_r(geteuid(),&value,buffer,sizeof(buffer),&result);
if(rc!=0||result==NULL||result->pw_name==NULL||result->pw_name[0]=='\0'||strlen(result->pw_name)>TRAINLOG_NAME_MAX)return false;
(void)snprintf(out,TRAINLOG_NAME_MAX+1U,"%s",result->pw_name);return true;
static bool prepare(TrainlogDatabase *d, const char *sql, sqlite3_stmt **s) {
return sqlite3_prepare_v2(d->connection, sql, -1, s, NULL) == SQLITE_OK;
}
static TrainlogStatus load_activity(TrainlogDatabase*d,int64_t now,TrainlogWebDashboardSnapshot*out){
static const char SQL[]=
static bool format_generated(int64_t now, char out[TRAINLOG_TIMESTAMP_MAX + 1U]) {
time_t value = (time_t)now;
struct tm utc;
if ((int64_t)value != now || gmtime_r(&value, &utc) == NULL) {
return false;
}
return strftime(out, TRAINLOG_TIMESTAMP_MAX + 1U, "%Y-%m-%dT%H:%M:%SZ", &utc) > 0U;
}
static bool format_local_date(time_t value, char out[11]) {
struct tm local;
return localtime_r(&value, &local) != NULL && strftime(out, 11U, "%Y-%m-%d", &local) == 10U;
}
static bool load_user(char out[TRAINLOG_NAME_MAX + 1U]) {
struct passwd value;
struct passwd *result = NULL;
char buffer[4096];
int rc = getpwuid_r(geteuid(), &value, buffer, sizeof(buffer), &result);
if (rc != 0 || result == NULL || result->pw_name == NULL || result->pw_name[0] == '\0' ||
strlen(result->pw_name) > TRAINLOG_NAME_MAX) {
return false;
}
(void)snprintf(out, TRAINLOG_NAME_MAX + 1U, "%s", result->pw_name);
return true;
}
static TrainlogStatus
load_activity(TrainlogDatabase *d, int64_t now, TrainlogWebDashboardSnapshot *out) {
static const char SQL[] =
"SELECT substr(s.started_at,1,10),COUNT(DISTINCT s.id),COUNT(ps.id) FROM sessions s "
"LEFT JOIN session_exercises se ON se.session_row_id=s.id LEFT JOIN performed_sets ps ON ps.session_exercise_row_id=se.id "
"WHERE unixepoch(s.started_at)<=?1 AND substr(s.started_at,1,10)>=?2 AND (EXISTS(SELECT 1 FROM session_exercises x JOIN performed_sets p ON p.session_exercise_row_id=x.id WHERE x.session_row_id=s.id) OR EXISTS(SELECT 1 FROM session_exercises x JOIN continuous_activity c ON c.session_exercise_row_id=x.id WHERE x.session_row_id=s.id) OR EXISTS(SELECT 1 FROM session_exercises x JOIN max_results m ON m.session_exercise_row_id=x.id WHERE x.session_row_id=s.id)) GROUP BY substr(s.started_at,1,10) ORDER BY 1;";
sqlite3_stmt*s=NULL;int step;size_t i;time_t reference=(time_t)now;struct tm current;
if((int64_t)reference!=now)return TRAINLOG_STATUS_DATABASE_ERROR;
if(localtime_r(&reference,&current)==NULL)return TRAINLOG_STATUS_DATABASE_ERROR;
out->activity_count=TRAINLOG_WEB_ACTIVITY_DAYS;
for(i=0U;i<TRAINLOG_WEB_ACTIVITY_DAYS;++i){struct tm day=current;time_t normalized;day.tm_mday-=(int)(TRAINLOG_WEB_ACTIVITY_DAYS-1U-i);day.tm_isdst=-1;normalized=mktime(&day);if(normalized==(time_t)-1||!format_local_date(normalized,out->activity[i].date))return TRAINLOG_STATUS_DATABASE_ERROR;}
if(!prepare(d,SQL,&s)||sqlite3_bind_int64(s,1,now)!=SQLITE_OK||sqlite3_bind_text(s,2,out->activity[0].date,-1,SQLITE_TRANSIENT)!=SQLITE_OK)goto fail;
while((step=sqlite3_step(s))==SQLITE_ROW){char date[11];size_t sessions,sets;if(!copy_column(s,0,date,sizeof(date))||!read_size(s,1,&sessions)||!read_size(s,2,&sets))goto fail;for(i=0U;i<out->activity_count;++i)if(strcmp(date,out->activity[i].date)==0){out->activity[i].active=true;out->activity[i].session_count=sessions;out->activity[i].set_count=sets;break;}}
if(step!=SQLITE_DONE||sqlite3_finalize(s)!=SQLITE_OK)return TRAINLOG_STATUS_DATABASE_ERROR;return TRAINLOG_STATUS_OK;
fail:if(s!=NULL)(void)sqlite3_finalize(s);return TRAINLOG_STATUS_DATABASE_ERROR;
"LEFT JOIN session_exercises se ON se.session_row_id=s.id LEFT JOIN performed_sets ps ON "
"ps.session_exercise_row_id=se.id "
"WHERE unixepoch(s.started_at)<=?1 AND substr(s.started_at,1,10)>=?2 AND (EXISTS(SELECT 1 "
"FROM session_exercises x JOIN performed_sets p ON p.session_exercise_row_id=x.id WHERE "
"x.session_row_id=s.id) OR EXISTS(SELECT 1 FROM session_exercises x JOIN "
"continuous_activity c ON c.session_exercise_row_id=x.id WHERE x.session_row_id=s.id) OR "
"EXISTS(SELECT 1 FROM session_exercises x JOIN max_results m ON "
"m.session_exercise_row_id=x.id WHERE x.session_row_id=s.id)) GROUP BY "
"substr(s.started_at,1,10) ORDER BY 1;";
sqlite3_stmt *s = NULL;
int step;
size_t i;
time_t reference = (time_t)now;
struct tm current;
if ((int64_t)reference != now) {
return TRAINLOG_STATUS_DATABASE_ERROR;
}
if (localtime_r(&reference, &current) == NULL) {
return TRAINLOG_STATUS_DATABASE_ERROR;
}
out->activity_count = TRAINLOG_WEB_ACTIVITY_DAYS;
for (i = 0U; i < TRAINLOG_WEB_ACTIVITY_DAYS; ++i) {
struct tm day = current;
time_t normalized;
day.tm_mday -= (int)(TRAINLOG_WEB_ACTIVITY_DAYS - 1U - i);
day.tm_isdst = -1;
normalized = mktime(&day);
if (normalized == (time_t)-1 || !format_local_date(normalized, out->activity[i].date)) {
return TRAINLOG_STATUS_DATABASE_ERROR;
}
}
if (!prepare(d, SQL, &s) || sqlite3_bind_int64(s, 1, now) != SQLITE_OK ||
sqlite3_bind_text(s, 2, out->activity[0].date, -1, SQLITE_TRANSIENT) != SQLITE_OK) {
goto fail;
}
while ((step = sqlite3_step(s)) == SQLITE_ROW) {
char date[11];
size_t sessions, sets;
if (!copy_column(s, 0, date, sizeof(date)) || !read_size(s, 1, &sessions) ||
!read_size(s, 2, &sets)) {
goto fail;
}
for (i = 0U; i < out->activity_count; ++i) {
if (strcmp(date, out->activity[i].date) == 0) {
out->activity[i].active = true;
out->activity[i].session_count = sessions;
out->activity[i].set_count = sets;
break;
}
}
}
if (step != SQLITE_DONE || sqlite3_finalize(s) != SQLITE_OK) {
return TRAINLOG_STATUS_DATABASE_ERROR;
}
return TRAINLOG_STATUS_OK;
fail:
if (s != NULL) {
(void)sqlite3_finalize(s);
}
return TRAINLOG_STATUS_DATABASE_ERROR;
}
static TrainlogStatus load_last(TrainlogDatabase*d,int64_t now,TrainlogWebLastSession*out){
static const char SESSION_SQL[]=
"SELECT s.session_id,s.started_at,COALESCE(s.ended_at,''),COUNT(DISTINCT CASE WHEN ps.id IS NOT NULL OR ca.id IS NOT NULL OR mr.session_exercise_row_id IS NOT NULL THEN se.id END),COUNT(DISTINCT ps.id),COUNT(DISTINCT ca.id),COUNT(DISTINCT mr.session_exercise_row_id) FROM sessions s JOIN session_exercises se ON se.session_row_id=s.id LEFT JOIN performed_sets ps ON ps.session_exercise_row_id=se.id LEFT JOIN continuous_activity ca ON ca.session_exercise_row_id=se.id LEFT JOIN max_results mr ON mr.session_exercise_row_id=se.id WHERE unixepoch(s.started_at)<=?1 AND (ps.id IS NOT NULL OR ca.id IS NOT NULL OR mr.session_exercise_row_id IS NOT NULL) GROUP BY s.id ORDER BY unixepoch(s.started_at) DESC,s.session_id DESC LIMIT 1;";
static const char ZONE_SQL[]=
"SELECT z.zone_id,COUNT(DISTINCT se.id),COUNT(DISTINCT ps.id) FROM sessions s JOIN session_exercises se ON se.session_row_id=s.id JOIN exercises e ON e.id=se.exercise_row_id JOIN exercise_body_zones z ON z.exercise_row_id=e.id AND z.role='primary' LEFT JOIN performed_sets ps ON ps.session_exercise_row_id=se.id WHERE s.session_id=?1 AND (EXISTS(SELECT 1 FROM performed_sets p WHERE p.session_exercise_row_id=se.id) OR EXISTS(SELECT 1 FROM continuous_activity c WHERE c.session_exercise_row_id=se.id) OR EXISTS(SELECT 1 FROM max_results m WHERE m.session_exercise_row_id=se.id)) GROUP BY z.zone_id ORDER BY z.zone_id;";
sqlite3_stmt*s=NULL;int step;TrainlogTimestampKey start,end;
if(!prepare(d,SESSION_SQL,&s)||sqlite3_bind_int64(s,1,now)!=SQLITE_OK)goto fail;step=sqlite3_step(s);if(step==SQLITE_DONE){(void)sqlite3_finalize(s);return TRAINLOG_STATUS_OK;}if(step!=SQLITE_ROW||!copy_column(s,0,out->session_id,sizeof(out->session_id))||!copy_column(s,1,out->started_at,sizeof(out->started_at))||!copy_column(s,2,out->ended_at,sizeof(out->ended_at))||!read_size(s,3,&out->exercise_count)||!read_size(s,4,&out->set_count)||!read_size(s,5,&out->continuous_count)||!read_size(s,6,&out->max_count))goto fail;
out->available=true;out->has_ended_at=out->ended_at[0]!='\0';
if(!trainlog_timestamp_parse(out->started_at,strlen(out->started_at),&start)){out->available=false;goto invalid;}
if(out->has_ended_at){if(!trainlog_timestamp_parse(out->ended_at,strlen(out->ended_at),&end)||end.utc_second<start.utc_second)goto invalid;out->has_duration=true;out->duration_seconds=(uint64_t)(end.utc_second-start.utc_second);}
if(sqlite3_step(s)!=SQLITE_DONE||sqlite3_finalize(s)!=SQLITE_OK)return TRAINLOG_STATUS_DATABASE_ERROR;s=NULL;
if(!prepare(d,ZONE_SQL,&s)||sqlite3_bind_text(s,1,out->session_id,-1,SQLITE_TRANSIENT)!=SQLITE_OK)goto fail;
while((step=sqlite3_step(s))==SQLITE_ROW){TrainlogWebWorkedZone*z;const TrainlogBodyZone*catalog;if(out->zone_count==TRAINLOG_WEB_ZONE_CAPACITY){return TRAINLOG_STATUS_DATABASE_ERROR;}z=&out->zones[out->zone_count];if(!copy_column(s,0,z->zone_id,sizeof(z->zone_id))||!read_size(s,1,&z->occurrence_count)||!read_size(s,2,&z->set_count))goto fail;z->session_count=1U;catalog=trainlog_body_zone_catalog_lookup(z->zone_id);if(catalog==NULL||strlen(catalog->display_name)>TRAINLOG_NAME_MAX)goto invalid;(void)snprintf(z->label,sizeof(z->label),"%s",catalog->display_name);++out->zone_count;}
if(step!=SQLITE_DONE||sqlite3_finalize(s)!=SQLITE_OK)return TRAINLOG_STATUS_DATABASE_ERROR;return TRAINLOG_STATUS_OK;
invalid:if(s!=NULL)(void)sqlite3_finalize(s);return TRAINLOG_STATUS_INVALID_ARGUMENT;
fail:if(s!=NULL)(void)sqlite3_finalize(s);return TRAINLOG_STATUS_DATABASE_ERROR;
static TrainlogStatus load_last(TrainlogDatabase *d, int64_t now, TrainlogWebLastSession *out) {
static const char SESSION_SQL[] =
"SELECT s.session_id,s.started_at,COALESCE(s.ended_at,''),COUNT(DISTINCT CASE WHEN ps.id "
"IS NOT NULL OR ca.id IS NOT NULL OR mr.session_exercise_row_id IS NOT NULL THEN se.id "
"END),COUNT(DISTINCT ps.id),COUNT(DISTINCT ca.id),COUNT(DISTINCT "
"mr.session_exercise_row_id) FROM sessions s JOIN session_exercises se ON "
"se.session_row_id=s.id LEFT JOIN performed_sets ps ON ps.session_exercise_row_id=se.id "
"LEFT JOIN continuous_activity ca ON ca.session_exercise_row_id=se.id LEFT JOIN "
"max_results mr ON mr.session_exercise_row_id=se.id WHERE unixepoch(s.started_at)<=?1 AND "
"(ps.id IS NOT NULL OR ca.id IS NOT NULL OR mr.session_exercise_row_id IS NOT NULL) GROUP "
"BY s.id ORDER BY unixepoch(s.started_at) DESC,s.session_id DESC LIMIT 1;";
static const char ZONE_SQL[] =
"SELECT z.zone_id,COUNT(DISTINCT se.id),COUNT(DISTINCT ps.id) FROM sessions s JOIN "
"session_exercises se ON se.session_row_id=s.id JOIN exercises e ON "
"e.id=se.exercise_row_id JOIN exercise_body_zones z ON z.exercise_row_id=e.id AND "
"z.role='primary' LEFT JOIN performed_sets ps ON ps.session_exercise_row_id=se.id WHERE "
"s.session_id=?1 AND (EXISTS(SELECT 1 FROM performed_sets p WHERE "
"p.session_exercise_row_id=se.id) OR EXISTS(SELECT 1 FROM continuous_activity c WHERE "
"c.session_exercise_row_id=se.id) OR EXISTS(SELECT 1 FROM max_results m WHERE "
"m.session_exercise_row_id=se.id)) GROUP BY z.zone_id ORDER BY z.zone_id;";
sqlite3_stmt *s = NULL;
int step;
TrainlogTimestampKey start, end;
if (!prepare(d, SESSION_SQL, &s) || sqlite3_bind_int64(s, 1, now) != SQLITE_OK) {
goto fail;
}
step = sqlite3_step(s);
if (step == SQLITE_DONE) {
(void)sqlite3_finalize(s);
return TRAINLOG_STATUS_OK;
}
if (step != SQLITE_ROW || !copy_column(s, 0, out->session_id, sizeof(out->session_id)) ||
!copy_column(s, 1, out->started_at, sizeof(out->started_at)) ||
!copy_column(s, 2, out->ended_at, sizeof(out->ended_at)) ||
!read_size(s, 3, &out->exercise_count) || !read_size(s, 4, &out->set_count) ||
!read_size(s, 5, &out->continuous_count) || !read_size(s, 6, &out->max_count)) {
goto fail;
}
out->available = true;
out->has_ended_at = out->ended_at[0] != '\0';
if (!trainlog_timestamp_parse(out->started_at, strlen(out->started_at), &start)) {
out->available = false;
goto invalid;
}
if (out->has_ended_at) {
if (!trainlog_timestamp_parse(out->ended_at, strlen(out->ended_at), &end) ||
end.utc_second < start.utc_second) {
goto invalid;
}
out->has_duration = true;
out->duration_seconds = (uint64_t)(end.utc_second - start.utc_second);
}
if (sqlite3_step(s) != SQLITE_DONE || sqlite3_finalize(s) != SQLITE_OK) {
return TRAINLOG_STATUS_DATABASE_ERROR;
}
s = NULL;
if (!prepare(d, ZONE_SQL, &s) ||
sqlite3_bind_text(s, 1, out->session_id, -1, SQLITE_TRANSIENT) != SQLITE_OK) {
goto fail;
}
while ((step = sqlite3_step(s)) == SQLITE_ROW) {
TrainlogWebWorkedZone *z;
const TrainlogBodyZone *catalog;
if (out->zone_count == TRAINLOG_WEB_ZONE_CAPACITY) {
return TRAINLOG_STATUS_DATABASE_ERROR;
}
z = &out->zones[out->zone_count];
if (!copy_column(s, 0, z->zone_id, sizeof(z->zone_id)) ||
!read_size(s, 1, &z->occurrence_count) || !read_size(s, 2, &z->set_count)) {
goto fail;
}
z->session_count = 1U;
catalog = trainlog_body_zone_catalog_lookup(z->zone_id);
if (catalog == NULL || strlen(catalog->display_name) > TRAINLOG_NAME_MAX) {
goto invalid;
}
(void)snprintf(z->label, sizeof(z->label), "%s", catalog->display_name);
++out->zone_count;
}
if (step != SQLITE_DONE || sqlite3_finalize(s) != SQLITE_OK) {
return TRAINLOG_STATUS_DATABASE_ERROR;
}
return TRAINLOG_STATUS_OK;
invalid:
if (s != NULL) {
(void)sqlite3_finalize(s);
}
return TRAINLOG_STATUS_INVALID_ARGUMENT;
fail:
if (s != NULL) {
(void)sqlite3_finalize(s);
}
return TRAINLOG_STATUS_DATABASE_ERROR;
}
static TrainlogStatus load_maxima(TrainlogDatabase*d,int64_t now,TrainlogWebDashboardSnapshot*out){
static const char SQL[]="SELECT s.session_id,se.entry_id,e.exercise_id,e.name,COALESCE(se.equipment_id,''),m.max_weight_kg,s.started_at FROM max_results m JOIN session_exercises se ON se.id=m.session_exercise_row_id JOIN sessions s ON s.id=se.session_row_id JOIN exercises e ON e.id=se.exercise_row_id WHERE unixepoch(s.started_at)<=?1 ORDER BY unixepoch(s.started_at) DESC,s.session_id DESC,se.entry_id DESC LIMIT ?2;";
sqlite3_stmt*s=NULL;int step;if(!prepare(d,SQL,&s)||sqlite3_bind_int64(s,1,now)!=SQLITE_OK||sqlite3_bind_int64(s,2,(sqlite3_int64)(TRAINLOG_WEB_MAX_RECORDS+1U))!=SQLITE_OK)goto fail;
while((step=sqlite3_step(s))==SQLITE_ROW){TrainlogWebMaxRecord*r;if(out->max_record_count==TRAINLOG_WEB_MAX_RECORDS){out->partial=true;continue;}r=&out->max_records[out->max_record_count];if(!copy_column(s,0,r->session_id,sizeof(r->session_id))||!copy_column(s,1,r->entry_id,sizeof(r->entry_id))||!copy_column(s,2,r->exercise_id,sizeof(r->exercise_id))||!copy_column(s,3,r->exercise_name,sizeof(r->exercise_name))||!copy_column(s,4,r->equipment_id,sizeof(r->equipment_id))||!copy_column(s,6,r->timestamp,sizeof(r->timestamp))||(sqlite3_column_type(s,5)!=SQLITE_FLOAT&&sqlite3_column_type(s,5)!=SQLITE_INTEGER)||!isfinite(sqlite3_column_double(s,5))||sqlite3_column_double(s,5)<=0.0){out->invalid_data=true;continue;}r->weight_kg=sqlite3_column_double(s,5);++out->max_record_count;}
if(step!=SQLITE_DONE||sqlite3_finalize(s)!=SQLITE_OK)return TRAINLOG_STATUS_DATABASE_ERROR;return TRAINLOG_STATUS_OK;
fail:if(s!=NULL)(void)sqlite3_finalize(s);return TRAINLOG_STATUS_DATABASE_ERROR;
static TrainlogStatus
load_maxima(TrainlogDatabase *d, int64_t now, TrainlogWebDashboardSnapshot *out) {
static const char SQL[] =
"SELECT "
"s.session_id,se.entry_id,e.exercise_id,e.name,COALESCE(se.equipment_id,''),m.max_weight_"
"kg,s.started_at FROM max_results m JOIN session_exercises se ON "
"se.id=m.session_exercise_row_id JOIN sessions s ON s.id=se.session_row_id JOIN exercises "
"e ON e.id=se.exercise_row_id WHERE unixepoch(s.started_at)<=?1 ORDER BY "
"unixepoch(s.started_at) DESC,s.session_id DESC,se.entry_id DESC LIMIT ?2;";
sqlite3_stmt *s = NULL;
int step;
if (!prepare(d, SQL, &s) || sqlite3_bind_int64(s, 1, now) != SQLITE_OK ||
sqlite3_bind_int64(s, 2, (sqlite3_int64)(TRAINLOG_WEB_MAX_RECORDS + 1U)) != SQLITE_OK) {
goto fail;
}
while ((step = sqlite3_step(s)) == SQLITE_ROW) {
TrainlogWebMaxRecord *r;
if (out->max_record_count == TRAINLOG_WEB_MAX_RECORDS) {
out->partial = true;
continue;
}
r = &out->max_records[out->max_record_count];
if (!copy_column(s, 0, r->session_id, sizeof(r->session_id)) ||
!copy_column(s, 1, r->entry_id, sizeof(r->entry_id)) ||
!copy_column(s, 2, r->exercise_id, sizeof(r->exercise_id)) ||
!copy_column(s, 3, r->exercise_name, sizeof(r->exercise_name)) ||
!copy_column(s, 4, r->equipment_id, sizeof(r->equipment_id)) ||
!copy_column(s, 6, r->timestamp, sizeof(r->timestamp)) ||
(sqlite3_column_type(s, 5) != SQLITE_FLOAT &&
sqlite3_column_type(s, 5) != SQLITE_INTEGER) ||
!isfinite(sqlite3_column_double(s, 5)) || sqlite3_column_double(s, 5) <= 0.0) {
out->invalid_data = true;
continue;
}
r->weight_kg = sqlite3_column_double(s, 5);
++out->max_record_count;
}
if (step != SQLITE_DONE || sqlite3_finalize(s) != SQLITE_OK) {
return TRAINLOG_STATUS_DATABASE_ERROR;
}
return TRAINLOG_STATUS_OK;
fail:
if (s != NULL) {
(void)sqlite3_finalize(s);
}
return TRAINLOG_STATUS_DATABASE_ERROR;
}
static TrainlogStatus load_zones(TrainlogDatabase*d,int64_t now,TrainlogWebDashboardSnapshot*out){
static const char SQL[]="SELECT z.zone_id,COUNT(DISTINCT s.id),COUNT(DISTINCT se.id),COUNT(DISTINCT ps.id) FROM sessions s JOIN session_exercises se ON se.session_row_id=s.id JOIN exercises e ON e.id=se.exercise_row_id JOIN exercise_body_zones z ON z.exercise_row_id=e.id AND z.role='primary' LEFT JOIN performed_sets ps ON ps.session_exercise_row_id=se.id WHERE substr(s.started_at,1,10)>=?1 AND unixepoch(s.started_at)<=?2 AND (EXISTS(SELECT 1 FROM performed_sets p WHERE p.session_exercise_row_id=se.id) OR EXISTS(SELECT 1 FROM continuous_activity c WHERE c.session_exercise_row_id=se.id) OR EXISTS(SELECT 1 FROM max_results m WHERE m.session_exercise_row_id=se.id)) GROUP BY z.zone_id ORDER BY z.zone_id;";
sqlite3_stmt*s=NULL;int step;if(!prepare(d,SQL,&s)||sqlite3_bind_text(s,1,out->activity[TRAINLOG_WEB_ACTIVITY_DAYS-30U].date,-1,SQLITE_TRANSIENT)!=SQLITE_OK||sqlite3_bind_int64(s,2,now)!=SQLITE_OK)goto fail;
while((step=sqlite3_step(s))==SQLITE_ROW){TrainlogWebWorkedZone*z;const TrainlogBodyZone*catalog;if(out->muscle_zone_count==TRAINLOG_WEB_ZONE_CAPACITY){out->partial=true;continue;}z=&out->muscle_zones[out->muscle_zone_count];if(!copy_column(s,0,z->zone_id,sizeof(z->zone_id))||!read_size(s,1,&z->session_count)||!read_size(s,2,&z->occurrence_count)||!read_size(s,3,&z->set_count))goto fail;catalog=trainlog_body_zone_catalog_lookup(z->zone_id);if(catalog==NULL||strlen(catalog->display_name)>TRAINLOG_NAME_MAX){out->invalid_data=true;continue;}(void)snprintf(z->label,sizeof(z->label),"%s",catalog->display_name);++out->muscle_zone_count;}
if(step!=SQLITE_DONE||sqlite3_finalize(s)!=SQLITE_OK)return TRAINLOG_STATUS_DATABASE_ERROR;return TRAINLOG_STATUS_OK;
fail:if(s!=NULL)(void)sqlite3_finalize(s);return TRAINLOG_STATUS_DATABASE_ERROR;
static TrainlogStatus
load_zones(TrainlogDatabase *d, int64_t now, TrainlogWebDashboardSnapshot *out) {
static const char SQL[] =
"SELECT z.zone_id,COUNT(DISTINCT s.id),COUNT(DISTINCT se.id),COUNT(DISTINCT ps.id) FROM "
"sessions s JOIN session_exercises se ON se.session_row_id=s.id JOIN exercises e ON "
"e.id=se.exercise_row_id JOIN exercise_body_zones z ON z.exercise_row_id=e.id AND "
"z.role='primary' LEFT JOIN performed_sets ps ON ps.session_exercise_row_id=se.id WHERE "
"substr(s.started_at,1,10)>=?1 AND unixepoch(s.started_at)<=?2 AND (EXISTS(SELECT 1 FROM "
"performed_sets p WHERE p.session_exercise_row_id=se.id) OR EXISTS(SELECT 1 FROM "
"continuous_activity c WHERE c.session_exercise_row_id=se.id) OR EXISTS(SELECT 1 FROM "
"max_results m WHERE m.session_exercise_row_id=se.id)) GROUP BY z.zone_id ORDER BY "
"z.zone_id;";
sqlite3_stmt *s = NULL;
int step;
if (!prepare(d, SQL, &s) ||
sqlite3_bind_text(
s, 1, out->activity[TRAINLOG_WEB_ACTIVITY_DAYS - 30U].date, -1, SQLITE_TRANSIENT) !=
SQLITE_OK ||
sqlite3_bind_int64(s, 2, now) != SQLITE_OK) {
goto fail;
}
while ((step = sqlite3_step(s)) == SQLITE_ROW) {
TrainlogWebWorkedZone *z;
const TrainlogBodyZone *catalog;
if (out->muscle_zone_count == TRAINLOG_WEB_ZONE_CAPACITY) {
out->partial = true;
continue;
}
z = &out->muscle_zones[out->muscle_zone_count];
if (!copy_column(s, 0, z->zone_id, sizeof(z->zone_id)) ||
!read_size(s, 1, &z->session_count) || !read_size(s, 2, &z->occurrence_count) ||
!read_size(s, 3, &z->set_count)) {
goto fail;
}
catalog = trainlog_body_zone_catalog_lookup(z->zone_id);
if (catalog == NULL || strlen(catalog->display_name) > TRAINLOG_NAME_MAX) {
out->invalid_data = true;
continue;
}
(void)snprintf(z->label, sizeof(z->label), "%s", catalog->display_name);
++out->muscle_zone_count;
}
if (step != SQLITE_DONE || sqlite3_finalize(s) != SQLITE_OK) {
return TRAINLOG_STATUS_DATABASE_ERROR;
}
return TRAINLOG_STATUS_OK;
fail:
if (s != NULL) {
(void)sqlite3_finalize(s);
}
return TRAINLOG_STATUS_DATABASE_ERROR;
}
static TrainlogStatus scan_quality(TrainlogDatabase*d,TrainlogWebDashboardSnapshot*out){
static const char SQL[]="SELECT s.started_at,COALESCE(s.ended_at,'') FROM sessions s WHERE EXISTS(SELECT 1 FROM session_exercises x JOIN performed_sets p ON p.session_exercise_row_id=x.id WHERE x.session_row_id=s.id) OR EXISTS(SELECT 1 FROM session_exercises x JOIN continuous_activity c ON c.session_exercise_row_id=x.id WHERE x.session_row_id=s.id) OR EXISTS(SELECT 1 FROM session_exercises x JOIN max_results m ON m.session_exercise_row_id=x.id WHERE x.session_row_id=s.id) ORDER BY s.id LIMIT 4097;";
sqlite3_stmt*s=NULL;int step;size_t count=0U;if(!prepare(d,SQL,&s))goto fail;while((step=sqlite3_step(s))==SQLITE_ROW){char started[TRAINLOG_TIMESTAMP_MAX+1U],ended[TRAINLOG_TIMESTAMP_MAX+1U];TrainlogTimestampKey start_key,end_key;++count;if(count>4096U){out->partial=true;continue;}if(!copy_column(s,0,started,sizeof(started))||!copy_column(s,1,ended,sizeof(ended))||!trainlog_timestamp_parse(started,strlen(started),&start_key)||(ended[0]!='\0'&&(!trainlog_timestamp_parse(ended,strlen(ended),&end_key)||trainlog_timestamp_compare(&end_key,&start_key)<0)))out->invalid_data=true;}if(step!=SQLITE_DONE||sqlite3_finalize(s)!=SQLITE_OK)return TRAINLOG_STATUS_DATABASE_ERROR;return TRAINLOG_STATUS_OK;fail:if(s!=NULL)(void)sqlite3_finalize(s);return TRAINLOG_STATUS_DATABASE_ERROR;
static TrainlogStatus scan_quality(TrainlogDatabase *d, TrainlogWebDashboardSnapshot *out) {
static const char SQL[] =
"SELECT s.started_at,COALESCE(s.ended_at,'') FROM sessions s WHERE EXISTS(SELECT 1 FROM "
"session_exercises x JOIN performed_sets p ON p.session_exercise_row_id=x.id WHERE "
"x.session_row_id=s.id) OR EXISTS(SELECT 1 FROM session_exercises x JOIN "
"continuous_activity c ON c.session_exercise_row_id=x.id WHERE x.session_row_id=s.id) OR "
"EXISTS(SELECT 1 FROM session_exercises x JOIN max_results m ON "
"m.session_exercise_row_id=x.id WHERE x.session_row_id=s.id) ORDER BY s.id LIMIT 4097;";
sqlite3_stmt *s = NULL;
int step;
size_t count = 0U;
if (!prepare(d, SQL, &s)) {
goto fail;
}
while ((step = sqlite3_step(s)) == SQLITE_ROW) {
char started[TRAINLOG_TIMESTAMP_MAX + 1U], ended[TRAINLOG_TIMESTAMP_MAX + 1U];
TrainlogTimestampKey start_key, end_key;
++count;
if (count > 4096U) {
out->partial = true;
continue;
}
if (!copy_column(s, 0, started, sizeof(started)) ||
!copy_column(s, 1, ended, sizeof(ended)) ||
!trainlog_timestamp_parse(started, strlen(started), &start_key) ||
(ended[0] != '\0' && (!trainlog_timestamp_parse(ended, strlen(ended), &end_key) ||
trainlog_timestamp_compare(&end_key, &start_key) < 0))) {
out->invalid_data = true;
}
}
if (step != SQLITE_DONE || sqlite3_finalize(s) != SQLITE_OK) {
return TRAINLOG_STATUS_DATABASE_ERROR;
}
return TRAINLOG_STATUS_OK;
fail:
if (s != NULL) {
(void)sqlite3_finalize(s);
}
return TRAINLOG_STATUS_DATABASE_ERROR;
}
TrainlogStatus trainlog_web_dashboard_load(TrainlogDatabase*d,const TrainlogWebDashboardQuery*q,TrainlogWebDashboardSnapshot*out){
TrainlogStatus status,end_status;TrainlogDashboardQuery progression_query;
if(d==NULL||q==NULL||out==NULL)return TRAINLOG_STATUS_INVALID_ARGUMENT;(void)memset(out,0,sizeof(*out));
out->next_session_reason="no_persisted_executable_plan";out->cardio_reason="no_cardio_data_source";
if(!format_generated(q->reference_unix_second,out->generated_at)||!load_user(out->user_display_name))return TRAINLOG_STATUS_DATABASE_ERROR;
status=trainlog_database_read_snapshot_begin(d);if(status!=TRAINLOG_STATUS_OK)return status;
status=load_activity(d,q->reference_unix_second,out);
if(status==TRAINLOG_STATUS_OK)status=scan_quality(d,out);
if(status==TRAINLOG_STATUS_OK){progression_query.period=TRAINLOG_DASHBOARD_90_DAYS;progression_query.reference_unix_second=q->reference_unix_second;status=trainlog_dashboard_load(d,&progression_query,&out->progression);out->partial=out->partial||out->progression.partial;out->invalid_data=out->invalid_data||out->progression.invalid_data;}
if(status==TRAINLOG_STATUS_OK){status=load_last(d,q->reference_unix_second,&out->last_session);if(status==TRAINLOG_STATUS_INVALID_ARGUMENT){out->invalid_data=true;status=TRAINLOG_STATUS_OK;}}
if(status==TRAINLOG_STATUS_OK)status=load_maxima(d,q->reference_unix_second,out);
if(status==TRAINLOG_STATUS_OK)status=load_zones(d,q->reference_unix_second,out);
end_status=trainlog_database_read_snapshot_end(d,status==TRAINLOG_STATUS_OK);if(end_status!=TRAINLOG_STATUS_OK)status=end_status;return status;
TrainlogStatus trainlog_web_dashboard_load(TrainlogDatabase *d,
const TrainlogWebDashboardQuery *q,
TrainlogWebDashboardSnapshot *out) {
TrainlogStatus status, end_status;
TrainlogDashboardQuery progression_query;
if (d == NULL || q == NULL || out == NULL) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
(void)memset(out, 0, sizeof(*out));
out->next_session_reason = "no_persisted_executable_plan";
out->cardio_reason = "no_cardio_data_source";
if (!format_generated(q->reference_unix_second, out->generated_at) ||
!load_user(out->user_display_name)) {
return TRAINLOG_STATUS_DATABASE_ERROR;
}
status = trainlog_database_read_snapshot_begin(d);
if (status != TRAINLOG_STATUS_OK) {
return status;
}
status = load_activity(d, q->reference_unix_second, out);
if (status == TRAINLOG_STATUS_OK) {
status = scan_quality(d, out);
}
if (status == TRAINLOG_STATUS_OK) {
progression_query.period = TRAINLOG_DASHBOARD_90_DAYS;
progression_query.reference_unix_second = q->reference_unix_second;
status = trainlog_dashboard_load(d, &progression_query, &out->progression);
out->partial = out->partial || out->progression.partial;
out->invalid_data = out->invalid_data || out->progression.invalid_data;
}
if (status == TRAINLOG_STATUS_OK) {
status = load_last(d, q->reference_unix_second, &out->last_session);
if (status == TRAINLOG_STATUS_INVALID_ARGUMENT) {
out->invalid_data = true;
status = TRAINLOG_STATUS_OK;
}
}
if (status == TRAINLOG_STATUS_OK) {
status = load_maxima(d, q->reference_unix_second, out);
}
if (status == TRAINLOG_STATUS_OK) {
status = load_zones(d, q->reference_unix_second, out);
}
end_status = trainlog_database_read_snapshot_end(d, status == TRAINLOG_STATUS_OK);
if (end_status != TRAINLOG_STATUS_OK) {
status = end_status;
}
return status;
}

View file

@ -3,26 +3,279 @@
#include <string.h>
#define RAW(value) do { if (!trainlog_json_raw(&w,(value))) goto overflow; } while (0)
#define STR(value) do { if (!trainlog_json_string(&w,(value))) goto overflow; } while (0)
#define NUM(value) do { if (!trainlog_json_size(&w,(value))) goto overflow; } while (0)
#define RAW(value) \
do { \
if (!trainlog_json_raw(&w, (value))) \
goto overflow; \
} while (0)
#define STR(value) \
do { \
if (!trainlog_json_string(&w, (value))) \
goto overflow; \
} while (0)
#define NUM(value) \
do { \
if (!trainlog_json_size(&w, (value))) \
goto overflow; \
} while (0)
static const char *tracking_name(TrainlogTrackingMode value){return value==TRAINLOG_TRACKING_DURATION?"duration":"reps";}
static const char *load_name(TrainlogLoadMode value){if(value==TRAINLOG_LOAD_EXTERNAL)return "external";if(value==TRAINLOG_LOAD_ASSISTANCE)return "assistance";return "none";}
static bool point_improved(const TrainlogDashboardSnapshot*p,size_t i){const TrainlogExercisePerformancePoint*a,*b;if(i==0U)return false;a=&p->performance[i-1U];b=&p->performance[i];if(!a->has_weight&&!b->has_weight)return b->metric_value>a->metric_value;if(a->has_weight!=b->has_weight)return false;if(p->performance_load_mode==TRAINLOG_LOAD_ASSISTANCE)return b->weight_kg<a->weight_kg||(b->weight_kg==a->weight_kg&&b->metric_value>a->metric_value);return b->weight_kg>a->weight_kg||(b->weight_kg==a->weight_kg&&b->metric_value>a->metric_value);}
static bool write_zone(TrainlogJsonWriter*w0,const TrainlogWebWorkedZone*z){TrainlogJsonWriter w=*w0;RAW("{\"zone_id\":");STR(z->zone_id);RAW(",\"label\":");STR(z->label);RAW(",\"session_count\":");NUM(z->session_count);RAW(",\"occurrence_count\":");NUM(z->occurrence_count);RAW(",\"set_count\":");NUM(z->set_count);RAW("}");*w0=w;return true;overflow:*w0=w;return false;}
TrainlogStatus trainlog_web_dashboard_serialize(const TrainlogWebDashboardSnapshot*s,char*out,size_t cap,size_t*out_size){
TrainlogJsonWriter w;size_t i;if(s==NULL||out==NULL||out_size==NULL)return TRAINLOG_STATUS_INVALID_ARGUMENT;*out_size=0U;trainlog_json_init(&w,out,cap);
RAW("{\"api_version\":1,\"data\":{\"footer\":{\"user\":");STR(s->user_display_name);RAW(",\"last_session_date\":");if(s->last_session.available){char date[11];(void)memcpy(date,s->last_session.started_at,10U);date[10]='\0';STR(date);}else RAW("null");RAW(",\"last_zones\":[");for(i=0U;i<s->last_session.zone_count;++i){if(i>0U)RAW(",");STR(s->last_session.zones[i].label);}RAW("]},");
RAW("\"next_session\":{\"available\":false,\"reason\":");STR(s->next_session_reason);RAW("},");
RAW("\"activity\":{\"available\":true,\"window_days\":90,\"days\":[");for(i=0U;i<s->activity_count;++i){const TrainlogWebActivityDay*d=&s->activity[i];if(i>0U)RAW(",");RAW("{\"date\":");STR(d->date);RAW(",\"active\":");RAW(d->active?"true":"false");RAW(",\"session_count\":");NUM(d->session_count);RAW(",\"set_count\":");NUM(d->set_count);RAW("}");}RAW("]},");
RAW("\"progression\":{");if(!s->progression.has_performance){RAW("\"available\":false,\"reason\":\"no_comparable_performance\"");}else{RAW("\"available\":true,\"identity\":{\"exercise_id\":");STR(s->progression.exercise.exercise_id);RAW(",\"exercise_name\":");STR(s->progression.exercise.name);RAW(",\"equipment_id\":");STR(s->progression.performance_equipment_id);RAW(",\"equipment_label\":");STR(s->progression.performance_equipment_label);RAW(",\"tracking_mode\":");STR(tracking_name(s->progression.performance_tracking_mode));RAW(",\"load_mode\":");STR(load_name(s->progression.performance_load_mode));RAW(",\"dose\":");if(!trainlog_json_i64(&w,(long long)s->progression.performance_dose))goto overflow;RAW("},\"points\":[");for(i=0U;i<s->progression.performance_count;++i){const TrainlogExercisePerformancePoint*p=&s->progression.performance[i];if(i>0U)RAW(",");RAW("{\"session_id\":");STR(p->session_id);RAW(",\"timestamp\":");STR(p->started_at);RAW(",\"metric_value\":");if(!trainlog_json_i64(&w,(long long)p->metric_value))goto overflow;RAW(",\"weight_kg\":");if(p->has_weight){if(!trainlog_json_double(&w,p->weight_kg))goto overflow;}else RAW("null");RAW(",\"improved\":");RAW(point_improved(&s->progression,i)?"true":"false");RAW("}");}RAW("]");}RAW("},");
RAW("\"last_session\":{");if(!s->last_session.available){RAW("\"available\":false,\"reason\":\"no_observable_session\"");}else{const TrainlogWebLastSession*l=&s->last_session;RAW("\"available\":true,\"session_id\":");STR(l->session_id);RAW(",\"started_at\":");STR(l->started_at);RAW(",\"ended_at\":");if(l->has_ended_at)STR(l->ended_at);else RAW("null");RAW(",\"duration_seconds\":");if(l->has_duration){if(!trainlog_json_i64(&w,(long long)l->duration_seconds))goto overflow;}else RAW("null");RAW(",\"exercise_count\":");NUM(l->exercise_count);RAW(",\"set_count\":");NUM(l->set_count);RAW(",\"continuous_count\":");NUM(l->continuous_count);RAW(",\"max_count\":");NUM(l->max_count);RAW(",\"primary_zones\":[");for(i=0U;i<l->zone_count;++i){if(i>0U)RAW(",");if(!write_zone(&w,&l->zones[i]))goto overflow;}RAW("]");}RAW("},");
RAW("\"max_records\":{\"available\":");RAW(s->max_record_count>0U?"true":"false");if(s->max_record_count==0U)RAW(",\"reason\":\"no_explicit_max_results\"");RAW(",\"records\":[");for(i=0U;i<s->max_record_count;++i){const TrainlogWebMaxRecord*r=&s->max_records[i];if(i>0U)RAW(",");RAW("{\"session_id\":");STR(r->session_id);RAW(",\"entry_id\":");STR(r->entry_id);RAW(",\"exercise_id\":");STR(r->exercise_id);RAW(",\"exercise_name\":");STR(r->exercise_name);RAW(",\"equipment_id\":");STR(r->equipment_id);RAW(",\"weight_kg\":");if(!trainlog_json_double(&w,r->weight_kg))goto overflow;RAW(",\"timestamp\":");STR(r->timestamp);RAW("}");}RAW("]},");
RAW("\"muscle_distribution\":{\"available\":");RAW(s->muscle_zone_count>0U?"true":"false");if(s->muscle_zone_count==0U)RAW(",\"reason\":\"no_worked_primary_zones\"");RAW(",\"window_days\":30,\"primary_zones\":[");for(i=0U;i<s->muscle_zone_count;++i){if(i>0U)RAW(",");if(!write_zone(&w,&s->muscle_zones[i]))goto overflow;}RAW("]},");
RAW("\"cardio\":{\"available\":false,\"reason\":");STR(s->cardio_reason);RAW("}},\"meta\":{\"partial\":");RAW(s->partial?"true":"false");RAW(",\"invalid_data\":");RAW(s->invalid_data?"true":"false");RAW(",\"generated_at\":");STR(s->generated_at);RAW("}}\n");
if(w.failed)goto overflow;*out_size=w.size;return TRAINLOG_STATUS_OK;
overflow:if(cap>0U)out[0]='\0';return TRAINLOG_STATUS_SYSTEM_ERROR;
static const char *tracking_name(TrainlogTrackingMode value) {
return value == TRAINLOG_TRACKING_DURATION ? "duration" : "reps";
}
static const char *load_name(TrainlogLoadMode value) {
if (value == TRAINLOG_LOAD_EXTERNAL) {
return "external";
}
if (value == TRAINLOG_LOAD_ASSISTANCE) {
return "assistance";
}
return "none";
}
static bool point_improved(const TrainlogDashboardSnapshot *p, size_t i) {
const TrainlogExercisePerformancePoint *a, *b;
if (i == 0U) {
return false;
}
a = &p->performance[i - 1U];
b = &p->performance[i];
if (!a->has_weight && !b->has_weight) {
return b->metric_value > a->metric_value;
}
if (a->has_weight != b->has_weight) {
return false;
}
if (p->performance_load_mode == TRAINLOG_LOAD_ASSISTANCE) {
return b->weight_kg < a->weight_kg ||
(b->weight_kg == a->weight_kg && b->metric_value > a->metric_value);
}
return b->weight_kg > a->weight_kg ||
(b->weight_kg == a->weight_kg && b->metric_value > a->metric_value);
}
static bool write_zone(TrainlogJsonWriter *w0, const TrainlogWebWorkedZone *z) {
TrainlogJsonWriter w = *w0;
RAW("{\"zone_id\":");
STR(z->zone_id);
RAW(",\"label\":");
STR(z->label);
RAW(",\"session_count\":");
NUM(z->session_count);
RAW(",\"occurrence_count\":");
NUM(z->occurrence_count);
RAW(",\"set_count\":");
NUM(z->set_count);
RAW("}");
*w0 = w;
return true;
overflow:
*w0 = w;
return false;
}
TrainlogStatus trainlog_web_dashboard_serialize(const TrainlogWebDashboardSnapshot *s,
char *out,
size_t cap,
size_t *out_size) {
TrainlogJsonWriter w;
size_t i;
if (s == NULL || out == NULL || out_size == NULL) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
*out_size = 0U;
trainlog_json_init(&w, out, cap);
RAW("{\"api_version\":1,\"data\":{\"footer\":{\"user\":");
STR(s->user_display_name);
RAW(",\"last_session_date\":");
if (s->last_session.available) {
char date[11];
(void)memcpy(date, s->last_session.started_at, 10U);
date[10] = '\0';
STR(date);
} else {
RAW("null");
}
RAW(",\"last_zones\":[");
for (i = 0U; i < s->last_session.zone_count; ++i) {
if (i > 0U) {
RAW(",");
}
STR(s->last_session.zones[i].label);
}
RAW("]},");
RAW("\"next_session\":{\"available\":false,\"reason\":");
STR(s->next_session_reason);
RAW("},");
RAW("\"activity\":{\"available\":true,\"window_days\":90,\"days\":[");
for (i = 0U; i < s->activity_count; ++i) {
const TrainlogWebActivityDay *d = &s->activity[i];
if (i > 0U) {
RAW(",");
}
RAW("{\"date\":");
STR(d->date);
RAW(",\"active\":");
RAW(d->active ? "true" : "false");
RAW(",\"session_count\":");
NUM(d->session_count);
RAW(",\"set_count\":");
NUM(d->set_count);
RAW("}");
}
RAW("]},");
RAW("\"progression\":{");
if (!s->progression.has_performance) {
RAW("\"available\":false,\"reason\":\"no_comparable_performance\"");
} else {
RAW("\"available\":true,\"identity\":{\"exercise_id\":");
STR(s->progression.exercise.exercise_id);
RAW(",\"exercise_name\":");
STR(s->progression.exercise.name);
RAW(",\"equipment_id\":");
STR(s->progression.performance_equipment_id);
RAW(",\"equipment_label\":");
STR(s->progression.performance_equipment_label);
RAW(",\"tracking_mode\":");
STR(tracking_name(s->progression.performance_tracking_mode));
RAW(",\"load_mode\":");
STR(load_name(s->progression.performance_load_mode));
RAW(",\"dose\":");
if (!trainlog_json_i64(&w, (long long)s->progression.performance_dose)) {
goto overflow;
}
RAW("},\"points\":[");
for (i = 0U; i < s->progression.performance_count; ++i) {
const TrainlogExercisePerformancePoint *p = &s->progression.performance[i];
if (i > 0U) {
RAW(",");
}
RAW("{\"session_id\":");
STR(p->session_id);
RAW(",\"timestamp\":");
STR(p->started_at);
RAW(",\"metric_value\":");
if (!trainlog_json_i64(&w, (long long)p->metric_value)) {
goto overflow;
}
RAW(",\"weight_kg\":");
if (p->has_weight) {
if (!trainlog_json_double(&w, p->weight_kg)) {
goto overflow;
}
} else {
RAW("null");
}
RAW(",\"improved\":");
RAW(point_improved(&s->progression, i) ? "true" : "false");
RAW("}");
}
RAW("]");
}
RAW("},");
RAW("\"last_session\":{");
if (!s->last_session.available) {
RAW("\"available\":false,\"reason\":\"no_observable_session\"");
} else {
const TrainlogWebLastSession *l = &s->last_session;
RAW("\"available\":true,\"session_id\":");
STR(l->session_id);
RAW(",\"started_at\":");
STR(l->started_at);
RAW(",\"ended_at\":");
if (l->has_ended_at) {
STR(l->ended_at);
} else {
RAW("null");
}
RAW(",\"duration_seconds\":");
if (l->has_duration) {
if (!trainlog_json_i64(&w, (long long)l->duration_seconds)) {
goto overflow;
}
} else {
RAW("null");
}
RAW(",\"exercise_count\":");
NUM(l->exercise_count);
RAW(",\"set_count\":");
NUM(l->set_count);
RAW(",\"continuous_count\":");
NUM(l->continuous_count);
RAW(",\"max_count\":");
NUM(l->max_count);
RAW(",\"primary_zones\":[");
for (i = 0U; i < l->zone_count; ++i) {
if (i > 0U) {
RAW(",");
}
if (!write_zone(&w, &l->zones[i])) {
goto overflow;
}
}
RAW("]");
}
RAW("},");
RAW("\"max_records\":{\"available\":");
RAW(s->max_record_count > 0U ? "true" : "false");
if (s->max_record_count == 0U) {
RAW(",\"reason\":\"no_explicit_max_results\"");
}
RAW(",\"records\":[");
for (i = 0U; i < s->max_record_count; ++i) {
const TrainlogWebMaxRecord *r = &s->max_records[i];
if (i > 0U) {
RAW(",");
}
RAW("{\"session_id\":");
STR(r->session_id);
RAW(",\"entry_id\":");
STR(r->entry_id);
RAW(",\"exercise_id\":");
STR(r->exercise_id);
RAW(",\"exercise_name\":");
STR(r->exercise_name);
RAW(",\"equipment_id\":");
STR(r->equipment_id);
RAW(",\"weight_kg\":");
if (!trainlog_json_double(&w, r->weight_kg)) {
goto overflow;
}
RAW(",\"timestamp\":");
STR(r->timestamp);
RAW("}");
}
RAW("]},");
RAW("\"muscle_distribution\":{\"available\":");
RAW(s->muscle_zone_count > 0U ? "true" : "false");
if (s->muscle_zone_count == 0U) {
RAW(",\"reason\":\"no_worked_primary_zones\"");
}
RAW(",\"window_days\":30,\"primary_zones\":[");
for (i = 0U; i < s->muscle_zone_count; ++i) {
if (i > 0U) {
RAW(",");
}
if (!write_zone(&w, &s->muscle_zones[i])) {
goto overflow;
}
}
RAW("]},");
RAW("\"cardio\":{\"available\":false,\"reason\":");
STR(s->cardio_reason);
RAW("}},\"meta\":{\"partial\":");
RAW(s->partial ? "true" : "false");
RAW(",\"invalid_data\":");
RAW(s->invalid_data ? "true" : "false");
RAW(",\"generated_at\":");
STR(s->generated_at);
RAW("}}\n");
if (w.failed) {
goto overflow;
}
*out_size = w.size;
return TRAINLOG_STATUS_OK;
overflow:
if (cap > 0U) {
out[0] = '\0';
}
return TRAINLOG_STATUS_SYSTEM_ERROR;
}

View file

@ -6,19 +6,250 @@
#include <unistd.h>
#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;i<node_count;i++)if(nodes[i].id==id)return &nodes[i];return NULL;}
static TrainlogStatus devices(TrainlogUsbDevice *o,size_t c,size_t *n){if(c<1U)return TRAINLOG_STATUS_INVALID_ARGUMENT;memset(o,0,sizeof(*o));o->bus_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<node_count;i++)if(nodes[i].parent==p){if(k>=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;i<node_count;i++)if(strcmp(nodes[i].path,path)==0){*id=nodes[i].id;return TRAINLOG_STATUS_OK;}return TRAINLOG_STATUS_SYSTEM_ERROR;}
static int copy(const char *a,const char *b){FILE *in=fopen(a,"rb"),*out=fopen(b,"wb");char buf[8192];size_t n;if(!in||!out)return -1;while((n=fread(buf,1,sizeof(buf),in))>0)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&&copy(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;i<node_count;i++)if(strcmp(nodes[i].path,path)==0){*id=nodes[i].id;return TRAINLOG_STATUS_OK;}return TRAINLOG_STATUS_SYSTEM_ERROR;}
static TrainlogStatus remove_object(unsigned int b,unsigned int d,uint32_t id){Node *n;(void)b;(void)d;rebuild();n=node(id);return n&&unlink(n->path)==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;}
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; i < node_count; i++) {
if (nodes[i].id == id) {
return &nodes[i];
}
}
return NULL;
}
static TrainlogStatus devices(TrainlogUsbDevice *o, size_t c, size_t *n) {
if (c < 1U) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
memset(o, 0, sizeof(*o));
o->bus_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 < node_count; i++) {
if (nodes[i].parent == p) {
if (k >= 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; i < node_count; i++) {
if (strcmp(nodes[i].path, path) == 0) {
*id = nodes[i].id;
return TRAINLOG_STATUS_OK;
}
}
return TRAINLOG_STATUS_SYSTEM_ERROR;
}
static int copy(const char *a, const char *b) {
FILE *in = fopen(a, "rb"), *out = fopen(b, "wb");
char buf[8192];
size_t n;
if (!in || !out) {
return -1;
}
while ((n = fread(buf, 1, sizeof(buf), in)) > 0) {
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 && copy(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; i < node_count; i++) {
if (strcmp(nodes[i].path, path) == 0) {
*id = nodes[i].id;
return TRAINLOG_STATUS_OK;
}
}
return TRAINLOG_STATUS_SYSTEM_ERROR;
}
static TrainlogStatus remove_object(unsigned int b, unsigned int d, uint32_t id) {
Node *n;
(void)b;
(void)d;
rebuild();
n = node(id);
return n && unlink(n->path) == 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 = devices,
.storages = storages,
.children = children,
.ensure_folder = ensure,
.receive = receive,
.send = send_file,
.remove = remove_object,
.rename = 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;
}

View file

@ -5,22 +5,297 @@
#include <unistd.h>
#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(cap<device_count)return TRAINLOG_STATUS_INVALID_ARGUMENT;*count=device_count;for(i=0;i<device_count;i++){memset(&out[i],0,sizeof(out[i]));out[i].bus_number=1U;out[i].device_number=(unsigned int)i+1U;}return TRAINLOG_STATUS_OK;}
static TrainlogStatus storages(unsigned int bus,unsigned int dev,TrainlogMtpStorage *out,size_t cap,size_t *count){(void)bus;if(cap<1U)return TRAINLOG_STATUS_INVALID_ARGUMENT;memset(out,0,sizeof(*out));out->storage_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<object_count;i++)if(objects[i].parent==parent && (dev==1U || objects[i].id<=3U)){if(n>=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;i<object_count;i++)if(objects[i].parent==p&&objects[i].folder&&strcmp(objects[i].name,name)==0){*id=objects[i].id;*created=false;return TRAINLOG_STATUS_OK;}*id=(uint32_t)(100U+object_count);*created=true;add(*id,p,true,name,NULL);return TRAINLOG_STATUS_OK;}
static Object *find(uint32_t id){size_t i;for(i=0;i<object_count;i++)if(objects[i].id==id)return &objects[i];return NULL;}
static TrainlogStatus receive(unsigned int b,unsigned int d,uint32_t id,const char *path){FILE *f;Object *o=find(id);size_t bytes;(void)b;(void)d;if(!o)return TRAINLOG_STATUS_NOT_FOUND;f=fopen(path,"wb");if(!f)return TRAINLOG_STATUS_SYSTEM_ERROR;bytes=id==9U?3U:o->size;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")<manifest);return 0;}
static int test_ambiguity_and_truncation(void){char root[]="/tmp/trainlog-generation-mtp-error-XXXXXX",diag[256]={0},path[512];TrainlogStatus status;Object *peer;reset();CHECK(mkdtemp(root)!=NULL);device_count=2U;CHECK(trainlog_generation_mtp_pull(&io,"peer_11111111-1111-4111-8111-111111111111",root,diag,sizeof(diag))==TRAINLOG_STATUS_CONFLICT);reset();peer=find(3);CHECK(peer!=NULL);snprintf(peer->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;}
#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 (cap < device_count) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
*count = device_count;
for (i = 0; i < device_count; i++) {
memset(&out[i], 0, sizeof(out[i]));
out[i].bus_number = 1U;
out[i].device_number = (unsigned int)i + 1U;
}
return TRAINLOG_STATUS_OK;
}
static TrainlogStatus
storages(unsigned int bus, unsigned int dev, TrainlogMtpStorage *out, size_t cap, size_t *count) {
(void)bus;
if (cap < 1U) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
memset(out, 0, sizeof(*out));
out->storage_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 < object_count; i++) {
if (objects[i].parent == parent && (dev == 1U || objects[i].id <= 3U)) {
if (n >= 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; i < object_count; i++) {
if (objects[i].parent == p && objects[i].folder && strcmp(objects[i].name, name) == 0) {
*id = objects[i].id;
*created = false;
return TRAINLOG_STATUS_OK;
}
}
*id = (uint32_t)(100U + object_count);
*created = true;
add(*id, p, true, name, NULL);
return TRAINLOG_STATUS_OK;
}
static Object *find(uint32_t id) {
size_t i;
for (i = 0; i < object_count; i++) {
if (objects[i].id == id) {
return &objects[i];
}
}
return NULL;
}
static TrainlogStatus receive(unsigned int b, unsigned int d, uint32_t id, const char *path) {
FILE *f;
Object *o = find(id);
size_t bytes;
(void)b;
(void)d;
if (!o) {
return TRAINLOG_STATUS_NOT_FOUND;
}
f = fopen(path, "wb");
if (!f) {
return TRAINLOG_STATUS_SYSTEM_ERROR;
}
bytes = id == 9U ? 3U : o->size;
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 = devices,
.storages = storages,
.children = children,
.ensure_folder = ensure_folder,
.receive = receive,
.send = send_file,
.remove = remove_object,
.rename = 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") < manifest);
return 0;
}
static int test_ambiguity_and_truncation(void) {
char root[] = "/tmp/trainlog-generation-mtp-error-XXXXXX", diag[256] = {0}, path[512];
TrainlogStatus status;
Object *peer;
reset();
CHECK(mkdtemp(root) != NULL);
device_count = 2U;
CHECK(trainlog_generation_mtp_pull(
&io, "peer_11111111-1111-4111-8111-111111111111", root, diag, sizeof(diag)) ==
TRAINLOG_STATUS_CONFLICT);
reset();
peer = find(3);
CHECK(peer != NULL);
snprintf(peer->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;
}

View file

@ -20,24 +20,28 @@
#include "trainlog/web_server.h"
#include "web_assets.h"
#define CHECK(value) do { if (!(value)) { \
(void)fprintf(stderr, "CHECK failed at %s:%d: %s\n", \
__FILE__, __LINE__, #value); return false; } } while (0)
#define CHECK(value) \
do { \
if (!(value)) { \
(void)fprintf(stderr, "CHECK failed at %s:%d: %s\n", __FILE__, __LINE__, #value); \
return false; \
} \
} while (0)
static volatile sig_atomic_t child_stop;
static void stop_child(int signal_number)
{
static void stop_child(int signal_number) {
(void)signal_number;
child_stop = 1;
}
static int reserve_port(uint16_t *port, int keep_open)
{
static int reserve_port(uint16_t *port, int keep_open) {
struct sockaddr_in address;
socklen_t length = (socklen_t)sizeof(address);
int socket_fd = socket(AF_INET, SOCK_STREAM, 0);
if (socket_fd < 0) return -1;
if (socket_fd < 0) {
return -1;
}
(void)memset(&address, 0, sizeof(address));
address.sin_family = AF_INET;
address.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
@ -56,8 +60,7 @@ static int reserve_port(uint16_t *port, int keep_open)
return socket_fd;
}
static int connect_with_retry(uint16_t port)
{
static int connect_with_retry(uint16_t port) {
struct sockaddr_in address;
struct timespec delay = {0, 10000000L};
size_t attempt;
@ -67,18 +70,19 @@ static int connect_with_retry(uint16_t port)
address.sin_port = htons(port);
for (attempt = 0U; attempt < 200U; ++attempt) {
int socket_fd = socket(AF_INET, SOCK_STREAM, 0);
if (socket_fd >= 0 && connect(socket_fd,
(const struct sockaddr *)&address, sizeof(address)) == 0)
if (socket_fd >= 0 &&
connect(socket_fd, (const struct sockaddr *)&address, sizeof(address)) == 0) {
return socket_fd;
if (socket_fd >= 0) (void)close(socket_fd);
}
if (socket_fd >= 0) {
(void)close(socket_fd);
}
(void)nanosleep(&delay, NULL);
}
return -1;
}
static bool exchange(uint16_t port, const char *request, char *response,
size_t capacity)
{
static bool exchange(uint16_t port, const char *request, char *response, size_t capacity) {
size_t sent = 0U;
size_t used = 0U;
size_t length = strlen(request);
@ -92,7 +96,9 @@ static bool exchange(uint16_t port, const char *request, char *response,
(void)shutdown(socket_fd, SHUT_WR);
while (used + 1U < capacity) {
ssize_t count = recv(socket_fd, response + used, capacity - used - 1U, 0);
if (count == 0) break;
if (count == 0) {
break;
}
CHECK(count > 0);
used += (size_t)count;
}
@ -101,19 +107,17 @@ static bool exchange(uint16_t port, const char *request, char *response,
return true;
}
static bool asset_path(const char *html, const char *suffix, char *output,
size_t capacity)
{
static bool asset_path(const char *html, const char *suffix, char *output, size_t capacity) {
const char *start = html;
size_t suffix_length = strlen(suffix);
while ((start = strstr(start, "/assets/")) != NULL) {
const char *end = start;
size_t length;
while (*end != '\0' && *end != '"' && *end != '\'' && *end != '<' &&
*end != '>') ++end;
while (*end != '\0' && *end != '"' && *end != '\'' && *end != '<' && *end != '>') {
++end;
}
length = (size_t)(end - start);
if (length >= suffix_length &&
memcmp(end - suffix_length, suffix, suffix_length) == 0 &&
if (length >= suffix_length && memcmp(end - suffix_length, suffix, suffix_length) == 0 &&
length < capacity) {
(void)memcpy(output, start, length);
output[length] = '\0';
@ -124,18 +128,28 @@ static bool asset_path(const char *html, const char *suffix, char *output,
return false;
}
static bool response_header(const char *response, const char *name,
char *output, size_t capacity)
{
const char *start = strstr(response, name); const char *end; size_t length;
if (start == NULL) return false; start += strlen(name);
end = strstr(start, "\r\n"); if (end == NULL) return false;
length = (size_t)(end - start); if (length >= capacity) return false;
(void)memcpy(output, start, length); output[length] = '\0'; return true;
static bool response_header(const char *response, const char *name, char *output, size_t capacity) {
const char *start = strstr(response, name);
const char *end;
size_t length;
if (start == NULL) {
return false;
}
start += strlen(name);
end = strstr(start, "\r\n");
if (end == NULL) {
return false;
}
length = (size_t)(end - start);
if (length >= capacity) {
return false;
}
(void)memcpy(output, start, length);
output[length] = '\0';
return true;
}
static bool test_http_contract(TrainlogDatabase *database)
{
static bool test_http_contract(TrainlogDatabase *database) {
char response[32768];
char large_request[12000];
char request[8192];
@ -156,44 +170,60 @@ static bool test_http_contract(TrainlogDatabase *database)
(void)sigemptyset(&action.sa_mask);
(void)sigaction(SIGTERM, &action, NULL);
child_stop = 0;
_exit(trainlog_web_server_run(database, "/tmp/trainlog-web-test.db", port, &child_stop,
diagnostic, sizeof(diagnostic)) == 0 ? 0 : 1);
_exit(trainlog_web_server_run(database,
"/tmp/trainlog-web-test.db",
port,
&child_stop,
diagnostic,
sizeof(diagnostic)) == 0
? 0
: 1);
}
CHECK(exchange(port,
"GET /api/v1/health HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n",
response, sizeof(response)));
"GET /api/v1/health HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n",
response,
sizeof(response)));
CHECK(strstr(response, "HTTP/1.1 200") != NULL);
CHECK(strstr(response, "Content-Type: application/json; charset=utf-8") != NULL);
CHECK(strstr(response, "X-Content-Type-Options: nosniff") != NULL);
CHECK(strstr(response, "Content-Security-Policy: default-src 'none'") != NULL);
CHECK(strstr(response, "Access-Control-Allow-Origin") == NULL);
CHECK(strstr(response,
"{\"api_version\":1,\"status\":\"ok\",\"product\":\"trainlog\","
"\"version\":\"0.1.2\"}") != NULL);
"{\"api_version\":1,\"status\":\"ok\",\"product\":\"trainlog\","
"\"version\":\"0.1.2\"}") != NULL);
{
char token[80];
CHECK(exchange(port,
"GET /api/v1/sync/status HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n",
response, sizeof(response)) && strstr(response, "HTTP/1.1 200") != NULL);
"GET /api/v1/sync/status HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n",
response,
sizeof(response)) &&
strstr(response, "HTTP/1.1 200") != NULL);
CHECK(strstr(response, "\"phase\":\"idle\"") != NULL);
CHECK(response_header(response, "X-Trainlog-CSRF-Token: ", token,
sizeof(token)) && strlen(token) == 64U);
(void)snprintf(request, sizeof(request),
CHECK(response_header(response, "X-Trainlog-CSRF-Token: ", token, sizeof(token)) &&
strlen(token) == 64U);
(void)snprintf(
request,
sizeof(request),
"POST /api/v1/sync HTTP/1.1\r\nHost: 127.0.0.1\r\n"
"Origin: http://127.0.0.1:%u\r\nContent-Type: application/json\r\n"
"X-Trainlog-CSRF-Token: %s\r\nContent-Length: 72\r\n\r\n"
"{\"request_id\":\"sy_11111111-1111-4111-8111-111111111111\",\"trigger\":\"web\"}",
(unsigned int)port, token);
(unsigned int)port,
token);
CHECK(exchange(port, request, response, sizeof(response)) &&
strstr(response, "HTTP/1.1 409") != NULL &&
strstr(response, "full_generation_disabled") != NULL);
strstr(response, "HTTP/1.1 409") != NULL &&
strstr(response, "full_generation_disabled") != NULL);
CHECK(exchange(port,
"GET /api/v1/sync HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n",
response, sizeof(response)) && strstr(response, "HTTP/1.1 405") != NULL);
"GET /api/v1/sync HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n",
response,
sizeof(response)) &&
strstr(response, "HTTP/1.1 405") != NULL);
}
CHECK(exchange(port,
"GET /api/v1/dashboard HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n",
response, sizeof(response)) && strstr(response, "HTTP/1.1 200") != NULL);
"GET /api/v1/dashboard HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n",
response,
sizeof(response)) &&
strstr(response, "HTTP/1.1 200") != NULL);
CHECK(strstr(response, "Content-Type: application/json; charset=utf-8") != NULL);
CHECK(strstr(response, "\"api_version\":1") != NULL);
CHECK(strstr(response, "\"no_persisted_executable_plan\"") != NULL);
@ -203,58 +233,94 @@ static bool test_http_contract(TrainlogDatabase *database)
{
TrainlogDashboardLayout layout;
char body[TRAINLOG_DASHBOARD_LAYOUT_JSON_CAPACITY];
char token[80]; char etag[32]; size_t body_size;
char token[80];
char etag[32];
size_t body_size;
CHECK(exchange(port,
"GET /api/v1/dashboard-layout HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n",
response, sizeof(response)) && strstr(response, "HTTP/1.1 200") != NULL);
"GET /api/v1/dashboard-layout HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n",
response,
sizeof(response)) &&
strstr(response, "HTTP/1.1 200") != NULL);
CHECK(strstr(response, "\"source\":\"default\"") != NULL);
CHECK(response_header(response, "ETag: ", etag, sizeof(etag)) && strcmp(etag, "\"0\"") == 0);
CHECK(response_header(response, "X-Trainlog-CSRF-Token: ", token, sizeof(token)) && strlen(token) == 64U);
trainlog_dashboard_layout_default(&layout); layout.tiles[3].y = 14U;
CHECK(trainlog_dashboard_layout_serialize(&layout,
TRAINLOG_DASHBOARD_LAYOUT_DEFAULT, false, body, sizeof(body), &body_size));
(void)snprintf(request, sizeof(request),
"PUT /api/v1/dashboard-layout HTTP/1.1\r\nHost: 127.0.0.1\r\n"
"Origin: http://127.0.0.1:%u\r\nContent-Type: application/json\r\n"
"If-Match: \"0\"\r\nX-Trainlog-CSRF-Token: wrong\r\n"
"Content-Length: %zu\r\n\r\n%s", (unsigned int)port, body_size, body);
CHECK(exchange(port, request, response, sizeof(response)) && strstr(response, "HTTP/1.1 403") != NULL);
(void)snprintf(request, sizeof(request),
"PUT /api/v1/dashboard-layout HTTP/1.1\r\nHost: 127.0.0.1\r\n"
"Origin: http://127.0.0.1:%u\r\nContent-Type: text/plain\r\n"
"If-Match: \"0\"\r\nX-Trainlog-CSRF-Token: %s\r\n"
"Content-Length: %zu\r\n\r\n%s", (unsigned int)port, token, body_size, body);
CHECK(exchange(port, request, response, sizeof(response)) && strstr(response, "HTTP/1.1 415") != NULL);
(void)snprintf(request, sizeof(request),
CHECK(response_header(response, "ETag: ", etag, sizeof(etag)) &&
strcmp(etag, "\"0\"") == 0);
CHECK(response_header(response, "X-Trainlog-CSRF-Token: ", token, sizeof(token)) &&
strlen(token) == 64U);
trainlog_dashboard_layout_default(&layout);
layout.tiles[3].y = 14U;
CHECK(trainlog_dashboard_layout_serialize(
&layout, TRAINLOG_DASHBOARD_LAYOUT_DEFAULT, false, body, sizeof(body), &body_size));
(void)snprintf(request,
sizeof(request),
"PUT /api/v1/dashboard-layout HTTP/1.1\r\nHost: 127.0.0.1\r\n"
"Origin: http://127.0.0.1:%u\r\nContent-Type: application/json\r\n"
"If-Match: \"0\"\r\nX-Trainlog-CSRF-Token: wrong\r\n"
"Content-Length: %zu\r\n\r\n%s",
(unsigned int)port,
body_size,
body);
CHECK(exchange(port, request, response, sizeof(response)) &&
strstr(response, "HTTP/1.1 403") != NULL);
(void)snprintf(request,
sizeof(request),
"PUT /api/v1/dashboard-layout HTTP/1.1\r\nHost: 127.0.0.1\r\n"
"Origin: http://127.0.0.1:%u\r\nContent-Type: text/plain\r\n"
"If-Match: \"0\"\r\nX-Trainlog-CSRF-Token: %s\r\n"
"Content-Length: %zu\r\n\r\n%s",
(unsigned int)port,
token,
body_size,
body);
CHECK(exchange(port, request, response, sizeof(response)) &&
strstr(response, "HTTP/1.1 415") != NULL);
(void)snprintf(
request,
sizeof(request),
"PUT /api/v1/dashboard-layout HTTP/1.1\r\nHost: 127.0.0.1\r\n"
"Origin: http://127.0.0.1:%u\r\nContent-Type: application/json\r\n"
"If-Match: \"0\"\r\nX-Trainlog-CSRF-Token: %s\r\nContent-Length: %zu\r\n\r\n%s",
(unsigned int)port, token, body_size, body);
CHECK(exchange(port, request, response, sizeof(response)) && strstr(response, "HTTP/1.1 200") != NULL);
CHECK(strstr(response, "\"revision\":1") != NULL && strstr(response, "\"source\":\"persisted\"") != NULL);
CHECK(exchange(port, request, response, sizeof(response)) && strstr(response, "HTTP/1.1 412") != NULL);
(void)snprintf(request, sizeof(request),
"DELETE /api/v1/dashboard-layout HTTP/1.1\r\nHost: 127.0.0.1\r\n"
"Origin: http://trainlog.perf\r\nIf-Match: \"0\"\r\n"
"X-Trainlog-CSRF-Token: %s\r\nContent-Length: 0\r\n\r\n", token);
CHECK(exchange(port, request, response, sizeof(response)) && strstr(response, "HTTP/1.1 412") != NULL);
(void)snprintf(request, sizeof(request),
"DELETE /api/v1/dashboard-layout HTTP/1.1\r\nHost: 127.0.0.1\r\n"
"Origin: http://trainlog.perf\r\nIf-Match: \"1\"\r\n"
"X-Trainlog-CSRF-Token: %s\r\nContent-Length: 0\r\n\r\n", token);
CHECK(exchange(port, request, response, sizeof(response)) && strstr(response, "HTTP/1.1 200") != NULL &&
strstr(response, "\"source\":\"default\"") != NULL);
(unsigned int)port,
token,
body_size,
body);
CHECK(exchange(port, request, response, sizeof(response)) &&
strstr(response, "HTTP/1.1 200") != NULL);
CHECK(strstr(response, "\"revision\":1") != NULL &&
strstr(response, "\"source\":\"persisted\"") != NULL);
CHECK(exchange(port, request, response, sizeof(response)) &&
strstr(response, "HTTP/1.1 412") != NULL);
(void)snprintf(request,
sizeof(request),
"DELETE /api/v1/dashboard-layout HTTP/1.1\r\nHost: 127.0.0.1\r\n"
"Origin: http://trainlog.perf\r\nIf-Match: \"0\"\r\n"
"X-Trainlog-CSRF-Token: %s\r\nContent-Length: 0\r\n\r\n",
token);
CHECK(exchange(port, request, response, sizeof(response)) &&
strstr(response, "HTTP/1.1 412") != NULL);
(void)snprintf(request,
sizeof(request),
"DELETE /api/v1/dashboard-layout HTTP/1.1\r\nHost: 127.0.0.1\r\n"
"Origin: http://trainlog.perf\r\nIf-Match: \"1\"\r\n"
"X-Trainlog-CSRF-Token: %s\r\nContent-Length: 0\r\n\r\n",
token);
CHECK(exchange(port, request, response, sizeof(response)) &&
strstr(response, "HTTP/1.1 200") != NULL &&
strstr(response, "\"source\":\"default\"") != NULL);
CHECK(exchange(port,
"PUT /api/v1/dashboard-layout HTTP/1.1\r\nHost: 127.0.0.1\r\n"
"Content-Type: application/json\r\nContent-Length: 2\r\n\r\n{}",
response, sizeof(response)) && strstr(response, "HTTP/1.1 403") != NULL);
"PUT /api/v1/dashboard-layout HTTP/1.1\r\nHost: 127.0.0.1\r\n"
"Content-Type: application/json\r\nContent-Length: 2\r\n\r\n{}",
response,
sizeof(response)) &&
strstr(response, "HTTP/1.1 403") != NULL);
}
CHECK(exchange(port,
"POST /api/v1/dashboard HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Length: 0\r\n\r\n",
response, sizeof(response)) && strstr(response, "HTTP/1.1 405") != NULL &&
strstr(response, "Allow: GET") != NULL);
CHECK(exchange(port, "GET / HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n",
response, sizeof(response)) && strstr(response, "HTTP/1.1 200") != NULL);
CHECK(
exchange(port,
"POST /api/v1/dashboard HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Length: 0\r\n\r\n",
response,
sizeof(response)) &&
strstr(response, "HTTP/1.1 405") != NULL && strstr(response, "Allow: GET") != NULL);
CHECK(exchange(port, "GET / HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n", response, sizeof(response)) &&
strstr(response, "HTTP/1.1 200") != NULL);
CHECK(strstr(response, "Content-Type: text/html; charset=utf-8") != NULL);
CHECK(strstr(response, "Cache-Control: no-cache") != NULL);
CHECK(strstr(response, "Content-Security-Policy: default-src 'self'") != NULL);
@ -263,81 +329,91 @@ static bool test_http_contract(TrainlogDatabase *database)
CHECK(strstr(response, "<title>Trainlog</title>") != NULL);
CHECK(asset_path(response, ".js", javascript_path, sizeof(javascript_path)));
CHECK(asset_path(response, ".css", stylesheet_path, sizeof(stylesheet_path)));
(void)snprintf(request, sizeof(request),
"GET %s HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n", javascript_path);
(void)snprintf(
request, sizeof(request), "GET %s HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n", javascript_path);
CHECK(exchange(port, request, response, sizeof(response)) &&
strstr(response, "HTTP/1.1 200") != NULL);
strstr(response, "HTTP/1.1 200") != NULL);
CHECK(strstr(response, "Content-Type: text/javascript; charset=utf-8") != NULL);
CHECK(strstr(response,
"Cache-Control: public, max-age=31536000, immutable") != NULL);
CHECK(strstr(response, "Cache-Control: public, max-age=31536000, immutable") != NULL);
CHECK(strstr(response, "ETag: \"sha256-") != NULL);
(void)snprintf(request, sizeof(request),
"GET %s HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n", stylesheet_path);
(void)snprintf(
request, sizeof(request), "GET %s HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n", stylesheet_path);
CHECK(exchange(port, request, response, sizeof(response)) &&
strstr(response, "Content-Type: text/css; charset=utf-8") != NULL);
strstr(response, "Content-Type: text/css; charset=utf-8") != NULL);
CHECK(
exchange(
port, "GET /analyse HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n", response, sizeof(response)) &&
strstr(response, "<title>Trainlog</title>") != NULL);
CHECK(exchange(port,
"GET /analyse HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n",
response, sizeof(response)) && strstr(response, "<title>Trainlog</title>") != NULL);
"GET /api/v1/unknown HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n",
response,
sizeof(response)) &&
strstr(response, "HTTP/1.1 404") != NULL &&
strstr(response, "<title>Trainlog</title>") == NULL);
CHECK(exchange(port,
"GET /api/v1/unknown HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n",
response, sizeof(response)) && strstr(response, "HTTP/1.1 404") != NULL &&
strstr(response, "<title>Trainlog</title>") == NULL);
"GET /assets/unknown.js HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n",
response,
sizeof(response)) &&
strstr(response, "HTTP/1.1 404") != NULL);
CHECK(exchange(port,
"GET /assets/unknown.js HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n",
response, sizeof(response)) && strstr(response, "HTTP/1.1 404") != NULL);
CHECK(exchange(port,
"GET /assets/../index.html HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n",
response, sizeof(response)) && strstr(response, "HTTP/1.1 404") != NULL);
(void)snprintf(request, sizeof(request),
"HEAD %s HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n", javascript_path);
"GET /assets/../index.html HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n",
response,
sizeof(response)) &&
strstr(response, "HTTP/1.1 404") != NULL);
(void)snprintf(
request, sizeof(request), "HEAD %s HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n", javascript_path);
CHECK(exchange(port, request, response, sizeof(response)) &&
strstr(response, "HTTP/1.1 200") != NULL &&
strstr(response, "Content-Type: text/javascript; charset=utf-8") != NULL);
strstr(response, "HTTP/1.1 200") != NULL &&
strstr(response, "Content-Type: text/javascript; charset=utf-8") != NULL);
CHECK(exchange(port,
"POST /api/v1/health HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Length: 0\r\n\r\n",
response, sizeof(response)) && strstr(response, "HTTP/1.1 405") != NULL);
"POST /api/v1/health HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Length: 0\r\n\r\n",
response,
sizeof(response)) &&
strstr(response, "HTTP/1.1 405") != NULL);
CHECK(strstr(response, "Allow: GET") != NULL);
CHECK(exchange(port,
"GET /api/v1/health HTTP/1.1\r\nHost: foreign.example\r\n\r\n",
response, sizeof(response)) && strstr(response, "HTTP/1.1 400") != NULL);
(void)snprintf(large_request, sizeof(large_request),
"POST /api/v1/health HTTP/1.1\r\nHost: 127.0.0.1\r\n"
"Content-Length: 5000\r\n\r\n%05000d", 0);
"GET /api/v1/health HTTP/1.1\r\nHost: foreign.example\r\n\r\n",
response,
sizeof(response)) &&
strstr(response, "HTTP/1.1 400") != NULL);
(void)snprintf(large_request,
sizeof(large_request),
"POST /api/v1/health HTTP/1.1\r\nHost: 127.0.0.1\r\n"
"Content-Length: 5000\r\n\r\n%05000d",
0);
CHECK(exchange(port, large_request, response, sizeof(response)) &&
strstr(response, "HTTP/1.1 413") != NULL);
strstr(response, "HTTP/1.1 413") != NULL);
(void)memset(large_request, 'a', sizeof(large_request));
(void)snprintf(large_request, 128U,
"GET /api/v1/health HTTP/1.1\r\nHost: 127.0.0.1\r\nX-Large: ");
(void)snprintf(
large_request, 128U, "GET /api/v1/health HTTP/1.1\r\nHost: 127.0.0.1\r\nX-Large: ");
{
size_t prefix = strlen(large_request);
(void)memset(large_request + prefix, 'a', 8500U);
(void)memcpy(large_request + prefix + 8500U, "\r\n\r\n", 5U);
}
CHECK(exchange(port, large_request, response, sizeof(response)) &&
(strstr(response, "HTTP/1.1 431") != NULL ||
strstr(response, "HTTP/1.1 400") != NULL));
(strstr(response, "HTTP/1.1 431") != NULL || strstr(response, "HTTP/1.1 400") != NULL));
CHECK(kill(child, SIGTERM) == 0);
CHECK(waitpid(child, &status, 0) == child);
CHECK(WIFEXITED(status) && WEXITSTATUS(status) == 0);
return true;
}
static bool test_port_in_use(TrainlogDatabase *database)
{
static bool test_port_in_use(TrainlogDatabase *database) {
volatile sig_atomic_t stop = 0;
char diagnostic[256];
uint16_t port;
int occupied = reserve_port(&port, 1);
CHECK(occupied >= 0);
CHECK(trainlog_web_server_run(database, "/tmp/trainlog-web-test.db", port, &stop, diagnostic,
sizeof(diagnostic)) != 0);
CHECK(trainlog_web_server_run(
database, "/tmp/trainlog-web-test.db", port, &stop, diagnostic, sizeof(diagnostic)) !=
0);
CHECK(strstr(diagnostic, "127.0.0.1") != NULL);
(void)close(occupied);
return true;
}
static bool test_dashboard_core_error_translation(void)
{
static bool test_dashboard_core_error_translation(void) {
char path[] = "/tmp/trainlog-web-error-XXXXXX";
char response[4096];
TrainlogDatabase *database = NULL;
@ -361,12 +437,15 @@ static bool test_dashboard_core_error_translation(void)
if (child == 0) {
volatile sig_atomic_t stop = 0;
char diagnostic[256];
_exit(trainlog_web_server_run(database, path, port, &stop, diagnostic,
sizeof(diagnostic)) == 0 ? 0 : 1);
_exit(trainlog_web_server_run(
database, path, port, &stop, diagnostic, sizeof(diagnostic)) == 0
? 0
: 1);
}
CHECK(exchange(port,
"GET /api/v1/dashboard HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n",
response, sizeof(response)));
"GET /api/v1/dashboard HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n",
response,
sizeof(response)));
CHECK(strstr(response, "HTTP/1.1 500") != NULL);
CHECK(strstr(response, "\"dashboard_unavailable\"") != NULL);
CHECK(kill(child, SIGTERM) == 0);
@ -376,23 +455,29 @@ static bool test_dashboard_core_error_translation(void)
return true;
}
int main(void)
{
int main(void) {
char config_root[] = "/tmp/trainlog-web-config-XXXXXX";
TrainlogDatabase *database = NULL;
bool passed;
if (mkdtemp(config_root) == NULL || setenv("XDG_CONFIG_HOME", config_root, 1) != 0)
if (mkdtemp(config_root) == NULL || setenv("XDG_CONFIG_HOME", config_root, 1) != 0) {
return 1;
if (trainlog_database_open(":memory:", &database) != TRAINLOG_STATUS_OK)
}
if (trainlog_database_open(":memory:", &database) != TRAINLOG_STATUS_OK) {
return 1;
}
if (!trainlog_web_assets_available()) {
volatile sig_atomic_t stop = 0;
char diagnostic[256];
passed = trainlog_web_server_run(database, "/tmp/trainlog-web-test.db", 8080U, &stop, diagnostic,
sizeof(diagnostic)) != 0 && strstr(diagnostic, "frontend") != NULL;
passed = trainlog_web_server_run(database,
"/tmp/trainlog-web-test.db",
8080U,
&stop,
diagnostic,
sizeof(diagnostic)) != 0 &&
strstr(diagnostic, "frontend") != NULL;
} else {
passed = test_http_contract(database) && test_port_in_use(database) &&
test_dashboard_core_error_translation();
test_dashboard_core_error_translation();
}
trainlog_database_close(database);
return passed ? 0 : 1;

View file

@ -2,21 +2,34 @@
#include <string.h>
#include "trainlog/generation_mtp.h"
int main(int argc, char **argv)
{
char diagnostic[1024] = {0}; TrainlogStatus status;
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 (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);
(void)fprintf(
stderr, "MTP adapter failed status=%d diagnostic=%s\n", (int)status, diagnostic);
return 2;
}
return 0;