Compare commits
5 commits
34c6c0b369
...
d29532a735
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d29532a735 | ||
|
|
3a740eb95d | ||
|
|
7d29dc83a2 | ||
|
|
7b769076c1 | ||
|
|
6cd6219ac5 |
31 changed files with 5051 additions and 1444 deletions
19
.clang-format
Normal file
19
.clang-format
Normal 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
|
||||
|
|
@ -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`;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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() }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(¤t, &source) != TRAINLOG_DASHBOARD_LAYOUT_OK)
|
||||
}
|
||||
if (trainlog_dashboard_layout_load(¤t, &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(¤t, &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(¤t, &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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);}
|
||||
|
|
|
|||
|
|
@ -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,¤t)==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, ¤t) == 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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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&©(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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue