Harden Android generation and supply chain
ober
325d8b6ee77a3efd8092e43c69212708b0cc20a9
--- a/.build.yml +++ b/.build.yml @@ -1,12 +1,19 @@ -image: alpine/edge +image: alpine/3.24 packages: - - chez-scheme - - git - - make + - chez-scheme=10.3.0-r2 + - git=2.54.0-r0 + - make=4.4.1-r4 sources: - - https://git.sr.ht/~lisp/jerboa + # Build dependency: full immutable commit, mirrored in dependencies.lock.json. + - "https://git.sr.ht/~lisp/jerboa#23ad83d28ac65cf6f0d2d702fd32934f207c26bf" + # The second source is the build subject selected by the SourceHut submitter. - https://git.sr.ht/~lisp/jerboa-android tasks: - smoke: | cd jerboa-android - JERBOA="chez --libdirs ../jerboa/lib --script" make test + test "$(apk info -v chez-scheme)" = chez-scheme-10.3.0-r2 + test "$(apk info -v git)" = git-2.54.0-r0 + test "$(apk info -v make)" = make-4.4.1-r4 + test "$(git -C ../jerboa rev-parse HEAD)" = 23ad83d28ac65cf6f0d2d702fd32934f207c26bf + test "$(git -C ../jerboa rev-parse 'HEAD^{tree}')" = 6cbc7dd433a88801de82540e74433fc04d2474cd + JERBOA="chez --libdirs .:../jerboa/lib --script" make test --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ build/ .gradle/ +.tools/ +dist/ local.properties *.apk *.aab --- a/Makefile +++ b/Makefile @@ -1,6 +1,9 @@ JERBOA ?= jerboa BUILD_DIR ?= build/counter +CLIENT_BUILD_DIR ?= build/client EXAMPLE ?= examples/counter/app.ss +CLIENT_EXAMPLE ?= examples/client/app.ss +SSD_EXAMPLE ?= tests/fixtures/ssd-app.ss JAVA_HOME ?= /opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home ANDROID_HOME ?= $(HOME)/Library/Android/sdk ANDROID_SDK_ROOT ?= $(ANDROID_HOME) @@ -9,13 +12,17 @@ export ANDROID_HOME export ANDROID_SDK_ROOT export PATH := $(JAVA_HOME)/bin:/opt/homebrew/bin:$(PATH) -.PHONY: help clean generate test apk +.PHONY: help clean generate test security supply-chain verify ssd-generate ssd-compile release-evidence apk +.NOTPARALLEL: help: @echo "Targets:" @echo " generate Generate the counter Android project" @echo " apk Generate and build the debug APK" - @echo " test Run the local generator smoke test" + @echo " test Run generator, adversarial, SSD, and supply-chain tests" + @echo " verify Run the release-required local verification suite" + @echo " ssd-compile Compile the SSD fixture with pinned Gradle/JDK tools" + @echo " release-evidence Emit SBOM/provenance from a clean committed tree" @echo " clean Remove generated output" clean: @@ -24,13 +31,50 @@ clean: generate: $(JERBOA) jandroid.ss generate $(EXAMPLE) $(BUILD_DIR) -apk: generate - gradle --no-daemon -p $(BUILD_DIR) assembleDebug +apk: clean + $(MAKE) generate + scripts/verify-android-sdk.sh + JDK_HOME=$$(scripts/verified-jdk.sh --home); \ + JAVA_HOME="$$JDK_HOME" scripts/verified-gradle.sh --no-daemon \ + -p $(BUILD_DIR) assembleDebug test: clean generate test -f $(BUILD_DIR)/settings.gradle.kts test -f $(BUILD_DIR)/app/build.gradle.kts + cmp supply-chain/gradle-verification-metadata.xml $(BUILD_DIR)/gradle/verification-metadata.xml test -f $(BUILD_DIR)/app/src/main/AndroidManifest.xml test -f $(BUILD_DIR)/app/src/main/java/org/jerboa/counter/MainActivity.kt grep -q 'class MainActivity' $(BUILD_DIR)/app/src/main/java/org/jerboa/counter/MainActivity.kt grep -q 'count = (count + 1); render()' $(BUILD_DIR)/app/src/main/java/org/jerboa/counter/MainActivity.kt + $(JERBOA) jandroid.ss generate $(CLIENT_EXAMPLE) $(CLIENT_BUILD_DIR) + test -f $(CLIENT_BUILD_DIR)/app/src/main/java/org/jerboa/sampleclient/SampleClient.kt + grep -q 'generated named client' $(CLIENT_BUILD_DIR)/app/src/main/java/org/jerboa/sampleclient/SampleClient.kt + $(MAKE) security + $(MAKE) supply-chain + +security: + JERBOA="$(JERBOA)" tests/security-test.sh + JERBOA="$(JERBOA)" tests/ssd-security-test.sh + +supply-chain: + scripts/verify-supply-chain.sh + +verify: test + +ssd-generate: clean + $(JERBOA) jandroid.ss generate $(SSD_EXAMPLE) build/ssd-security + +ssd-compile: ssd-generate + scripts/verify-android-sdk.sh + JDK_HOME=$$(scripts/verified-jdk.sh --home); \ + JANDROID_EXPECTED_RUNTIME_LOCK="$(CURDIR)/tests/fixtures/ssd-runtime.lock.json" \ + JANDROID_EXPECTED_BUILD_LOCK="$(CURDIR)/tests/fixtures/ssd-build.lock.json" \ + JANDROID_RESOLVED_SBOM="$(CURDIR)/build/ssd-security/resolved-android-runtime.spdx.json" \ + JANDROID_RESOLVED_BUILD_SBOM="$(CURDIR)/build/ssd-security/resolved-gradle-build.spdx.json" \ + JANDROID_SOURCE_COMMIT=$$(git rev-parse HEAD) \ + JAVA_HOME="$$JDK_HOME" scripts/verified-gradle.sh --no-daemon \ + -I "$(CURDIR)/scripts/resolved-artifacts.init.gradle" \ + -p build/ssd-security compileDebugKotlin writeResolvedRuntimeSbom writeResolvedBuildSbom + +release-evidence: + scripts/release-evidence.sh --- a/README.md +++ b/README.md @@ -10,19 +10,23 @@ code. ## Quick Start ```sh -make test -make generate +make verify +make clean generate ``` +`make verify` runs the release-required adversarial and supply-chain checks. +`make ssd-compile` performs the heavier generated-Android compile with the +repository-pinned Gradle and JDK artifacts. See [SECURITY.md](SECURITY.md) for +the trust boundaries and release policy. + The generated counter project is written to `build/counter`. -```sh -cd build/counter -gradle assembleDebug -``` +Use `make apk` to regenerate it and build with the authenticated, pinned Gradle +and JDK artifacts. -The generated project uses Android Gradle Plugin 9.2.0 and the built-in Kotlin -support introduced in AGP 9.x. +The generated project defaults to Android Gradle Plugin 8.13.2 and Kotlin +2.0.21, because those versions are already used by the larger game app. Specs +can override both versions. ## App Spec Shape @@ -35,6 +39,8 @@ Specs are valid Jerboa files that define a quoted `app` value: '(android-app (id "org.jerboa.counter") (name "Jerboa Counter") + (version-code 1) + (version-name "0.1.0") (screen Main (state count 0) (column @@ -49,6 +55,7 @@ Current supported view forms: - `(text part ...)` - `(button label action ...)` - `(spacer height)` +- `(small-text part ...)` Current supported action form: @@ -59,6 +66,80 @@ Current supported expression forms: - strings, numbers, booleans, symbols - binary `+`, `-`, `*`, `/` +Android project metadata supported by the generator: + +- `(id "com.example.app")` +- `(name "Display Name")` +- `(root-name "GradleRootName")` +- `(compile-sdk 35)`, `(build-tools-version "36.0.0")`, `(min-sdk 26)`, `(target-sdk 35)` +- `(version-code 10)`, `(version-name "0.1.9")` +- `(android-gradle-plugin "8.13.2")`, `(kotlin-version "2.0.21")` +- `(jvm-toolchain 17)` +- `(gradle-property "android.useAndroidX" "true")` +- `(permission "android.permission.INTERNET")` +- `(permission "android.permission.READ_EXTERNAL_STORAGE" (max-sdk 32))` +- `(allow-backup #t)`, `(uses-cleartext-traffic #t)` +- `(theme "@android:style/Theme.Material.NoActionBar")` +- `(ndk-version "28.2.13676358")`, `(abi-filter "arm64-v8a")` +- `(asset-dir "relative/path")` +- `(jni-lib-dir "relative/path")` +- `(dependency "androidx.documentfile:documentfile:1.1.0")` +- `(kotlin-source-dir "relative/path")` +- `(fragment "relative/path.ss")` +- `(client original-tactics)` + +`asset-dir` and `jni-lib-dir` copy directory contents into the generated +Android project. This lets app repos keep only Jerboa specs and binary assets +under source control while Kotlin remains generated output. + +`kotlin-source-dir` is a migration bridge for existing apps. It copies existing +Kotlin source into the generated project after the default generated +`MainActivity.kt`, so a large app can move to Jerboa-owned Android project +generation before each view is rewritten as higher-level Jerboa forms. + +`fragment` reads another Jerboa file that defines: + +```scheme +(def fragment + '((kotlin-file "com/example/Extra.kt" "package com.example\n..."))) +``` + +Large fragments can use line lists instead of one string: + +```scheme +(def fragment + '((kotlin-file-lines "com/example/Extra.kt" + ("package com.example" + "" + "class Extra")))) +``` + +Fragments are expanded into the app spec before generation. They are useful for +large generated source sets that should stay in `.ss` files instead of the main +app spec. + +`client` expands a named generator-owned template from `templates/<name>.ss`. +This is the preferred bridge for larger apps while the higher-level Android DSL +is growing, because application repositories can keep concise Jerboa app specs +instead of carrying large generated-source fragments. + +All source paths are relative to the app specification and may not contain +symbolic links. Output paths are descriptor-relative, no-follow, exclusive +creates; generation intentionally fails if a target already exists. Dependency +coordinates must use an exact `group:name:version` (no `+`, ranges, `latest`, or +snapshot selectors). Generated projects include strict Gradle verification +metadata for the reviewed graph; adding a dependency also requires a reviewed +checksum update (or an application-owned verification manifest) before Gradle +will execute the build. The generated settings also constrain known-vulnerable +transitive build dependencies to the reviewed versions recorded in +`dependencies.lock.json`. + +The `ssd-review` client keeps remote synchronization disabled by default. A +deployment must locally provision an HTTPS origin, SPKI SHA-256 pin, and bearer +token; remote identifiers are hashed into local filenames and all HTTP, ZIP, +entry-count, expansion-ratio, and storage budgets are enforced. The precise +configuration and limits are documented in [SECURITY.md](SECURITY.md). + ## Direction The next useful step is a typed backend beside Jerboa's existing Rust and LLVM new file mode 100644 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,99 @@ +# Security model + +`jerboa-android` treats an app specification and every referenced source tree +as untrusted build input. Generation is confined to one output-directory +capability; generated Android applications have a separate runtime boundary. + +## Generator output boundary + +The generator opens the physical output root once and resolves every directory +and file relative to that descriptor. It rejects absolute output names, empty, +`.` and `..` components, alternate separators, NULs, unexpected top-level +directories/extensions, invalid Java package components, and symbolic-link +parents. Files are created exclusively with no-follow semantics and mode 0600; +an existing output is never replaced. Use a new or empty output directory for +each generation. + +Referenced fragments and copied source directories must be relative to the app +specification. Symbolic links are rejected. Copying is limited to 10,000 files, +32 MiB per file, 512 MiB total, and depth 32. Assets accept regular files, JNI +trees accept only `.so`, and Kotlin trees accept only `.kt`. + +These controls protect against an untrusted repository changing an inode +outside the requested output tree. They do not make generated Kotlin or Gradle +code safe to execute: a specification can intentionally emit application code, +so review it before building or installing the result. + +## SSD review client + +Remote synchronization is disabled unless trusted local provisioning writes all +three values to the app-private `ssd_review_remote` preferences: + +- `base_url`: an HTTPS origin without user information, query, or fragment +- `spki_sha256`: `sha256/` plus the base64 SHA-256 of the expected leaf SPKI +- `bearer_token`: a locally issued bearer token of 32–4096 characters + +The client requires normal platform certificate validation and the configured +SPKI pin during the TLS handshake, sends credentials only to the configured +same origin, and refuses redirects. Rotate a pin through a trusted application +update or device-management channel before the old certificate is removed. +Production deployments should provision bearer material through a +Keystore-backed mechanism; app-private preferences do not protect a compromised +or rooted device. + +Remote `source_key` values are narrow identifiers, never paths. Local filenames +are SHA-256-derived opaque leaves under app-private directories, and writes use +contained no-follow parents plus an fsynced atomic move. User-selected sync +locations come only from Android's Storage Access Framework. + +Network/import limits are 2 MiB each for compressed and expanded text, 32 MiB compressed ZIP, 4 MiB per +expanded entry, 64 MiB total expansion, 512 entries, depth 8, 240 path +characters, and a 100:1 compression ratio after a 64 KiB floor. App-private +storage is capped at 128 MiB and 4096 files. Imports stage on disk, enforce all +budgets before installation, and delete partial staging on failure. + +## Build and release assurance + +[`dependencies.lock.json`](dependencies.lock.json) is the machine-readable +input manifest. The Jerboa source dependency is pinned by commit and tree in +SourceHut; Android plugin, Kotlin, Gradle distribution digest, Temurin JDK +artifacts, SDK/API revisions, build tools, and SSD AndroidX dependency are +declared there. The SourceHut smoke image release and installed package builds +are also fixed. Dynamic Maven selectors and snapshot versions are rejected. +The SSD fixture's complete resolved runtime and executable Gradle plugin graphs +and artifact digests are checked against `tests/fixtures/ssd-runtime.lock.json` +and `tests/fixtures/ssd-build.lock.json` during compilation. Every generated +project also receives strict Gradle verification metadata, so plugin, compiler, +metadata, and runtime artifacts are checked before use. Reviewed resolution +constraints keep vulnerable transitive plugin requests off the effective build +classpath; both the constraints and resolved artifact hashes are locked. + +`make verify` is the required local gate. `make ssd-compile` additionally +downloads Gradle and JDK only over HTTPS, authenticates them with repository-held +SHA-256 values, re-extracts them, and compiles the generated SSD Kotlin source. +`make release-evidence` refuses a dirty worktree, runs through the pinned Jerboa +source with Chez 10.3.0, compiles the SSD fixture, and emits source/tree +identity, tool versions, checksums, a declared-tool SPDX document, and SHA-256 +inventories of every resolved Android runtime and executable build-classpath +artifact. +The release gate also runs the pinned OSV-Scanner 2.4.0 binary against both the +resolved runtime and resolved build-classpath SPDX documents, fails on a +reported vulnerability, and retains the JSON results and scan time as evidence. +The verification manifest is intentionally a superset because Gradle records +metadata candidates considered during conflict resolution; advisory decisions +therefore use the locked effective classpaths, not evicted candidates. + +SUPPLY-03 is not applicable because CI does not download a Jerboa executable; +it executes the exact pinned Jerboa source checkout. SUPPLY-04 is not currently +applicable because this repository has no GitHub Actions workflow, but the gate +rejects future non-SHA action references. The repository-under-test SourceHut +source is the build subject selected by the submitter, not a dependency; its +commit and tree are recorded in release evidence. + +Generated applications can add their own Maven, native, asset, and model inputs. +Those are outside this repository's fixed graph and must have their own Gradle +dependency verification metadata/locks, resolved-artifact SBOM, advisory scan, +and provenance before an APK is released. + +Report suspected vulnerabilities privately to the maintainers and avoid +including credentials or sensitive documents in a report. new file mode 100644 --- /dev/null +++ b/dependencies.lock.json @@ -0,0 +1,74 @@ +{ + "schema": 1, + "sourcehut_smoke_environment": { + "image": "alpine/3.24", + "packages": { + "chez-scheme": "10.3.0-r2", + "git": "2.54.0-r0", + "make": "4.4.1-r4" + } + }, + "generator_runtime": { + "name": "jerboa", + "repository": "https://git.sr.ht/~lisp/jerboa", + "commit": "23ad83d28ac65cf6f0d2d702fd32934f207c26bf", + "tree": "6cbc7dd433a88801de82540e74433fc04d2474cd" + }, + "assurance_tools": { + "osv_scanner": { + "version": "2.4.0", + "macos_aarch64_url": "https://github.com/google/osv-scanner/releases/download/v2.4.0/osv-scanner_darwin_arm64", + "macos_aarch64_sha256": "9ca3185ad63e9ab54f7cb90f46a7362be02d80e37f0123d095a54355ea202f5d", + "linux_x86_64_url": "https://github.com/google/osv-scanner/releases/download/v2.4.0/osv-scanner_linux_amd64", + "linux_x86_64_sha256": "15314940c10d26af9c6649f150b8a47c1262e8fc7e17b1d1029b0e479e8ed8a0" + } + }, + "generated_android_defaults": { + "android_gradle_plugin": "8.13.2", + "kotlin_gradle_plugin": "2.0.21", + "build_classpath_security_overrides": { + "io.netty": "4.1.135.Final", + "org.bouncycastle": "1.84", + "com.google.protobuf": "3.25.5", + "org.apache.commons:commons-compress": "1.28.0", + "org.bitbucket.b_c:jose4j": "0.9.6", + "org.jdom:jdom2": "2.0.6.1" + }, + "gradle": { + "version": "8.13", + "distribution": "https://services.gradle.org/distributions/gradle-8.13-bin.zip", + "sha256": "20f1b1176237254a6fc204d8434196fa11a4cfb387567519c61556e8710aed78" + }, + "jdk": { + "version": "17.0.19+10", + "macos_aarch64_url": "https://github.com/adoptium/temurin17-binaries/releases/download/jdk-17.0.19%2B10/OpenJDK17U-jdk_aarch64_mac_hotspot_17.0.19_10.tar.gz", + "macos_aarch64_sha256": "8fa1eff40bb637a33613b2ccb8b12c70dc3661cc22cf8e784943715769a05336", + "linux_x86_64_url": "https://github.com/adoptium/temurin17-binaries/releases/download/jdk-17.0.19%2B10/OpenJDK17U-jdk_x64_linux_hotspot_17.0.19_10.tar.gz", + "linux_x86_64_sha256": "d8afc263758141a66e0e3aafc321e783f7016696f4eaea067d340a269037d331" + }, + "jdk_language_version": 17, + "android_sdk_platforms": {"35": 2, "36": 2}, + "android_build_tools": "36.0.0", + "aapt2": { + "version": "8.13.2-14304508", + "macos_sha256": "0d47f17c3924e5472b6125aa608d949dd7f46510889729671f31f2f4d801e8e7", + "linux_x86_64_sha256": "839609d6d776d6dd60a02aa577d97193ce3e650cf1deaabf062321e23bbd6bf6" + }, + "gradle_verification_metadata_sha256": "a3e045c36af61ccc34e805761b1a1a42a00b6f88c5a98abd0d058e4f1ef3a97b", + "android_compile_sdk": 35, + "android_target_sdk": 35, + "android_min_sdk": 26 + }, + "ssd_review_fixture": { + "androidx_documentfile": "androidx.documentfile:documentfile:1.1.0", + "resolved_runtime_lock": { + "path": "tests/fixtures/ssd-runtime.lock.json", + "sha256": "beb65a1320dafa71ba8c74ac0f06cbbe55bd62250d90ab02f53092be3e0ffa6f" + }, + "resolved_build_classpath_lock": { + "path": "tests/fixtures/ssd-build.lock.json", + "sha256": "a41508b7d2b016f3c8aef0ac7877922b147aaa532b0be0a1cf5824fe01522f99" + } + }, + "github_actions": [] +} --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -12,6 +12,12 @@ - Add text input, lists, checkboxes, and simple resources. - Add generated tests for the emitted Kotlin files. - Add an Android SDK CI job once the SourceHut image setup is settled. +- Support generated game clients that need assets, JNI libraries, permissions, + and Android SDK/NDK metadata. +- Add higher-level generator forms for custom Canvas views so apps do not need + hand-written Kotlin source files for tactical maps or board views. +- Retire `kotlin-source-dir` once the custom view generator can describe the + SFC-style tactical panels directly. ## Compiler Track new file mode 100644 --- /dev/null +++ b/examples/client/app.ss @@ -0,0 +1,14 @@ +(import (jerboa prelude)) + +(def app + '(android-app + (id "org.jerboa.sampleclient") + (name "Jerboa Client Example") + (version-code 1) + (version-name "0.1.0") + (client sample-client) + (screen Main + (state status "ready") + (column + (text "Named Client") + (small-text "Status: " status))))) --- a/jandroid.ss +++ b/jandroid.ss @@ -1,5 +1,12 @@ (import (jerboa prelude) - (only (chezscheme) mkdir file-directory?)) + (only (chezscheme) file-directory?) + (jandroid secure-output)) + +(def *max-copy-files* 10000) +(def *max-copy-file-bytes* (* 32 1024 1024)) +(def *max-copy-total-bytes* (* 512 1024 1024)) +(def *max-copy-depth* 32) +(defstruct copy-budget (files bytes)) (def (usage) (displayln "Usage:") @@ -31,23 +38,71 @@ #f)) (error 'find-app "expected `(def app '(...))` in app spec"))) -(def (ensure-directory path) - (unless (or (string=? path "") - (string=? path ".") - (file-directory? path)) - (let ((parent (path-directory path))) - (when (and (not (string=? parent "")) - (not (string=? parent ".")) - (not (string=? parent path))) - (ensure-directory parent))) - (try (mkdir path) - (catch (exn) (void))))) +(def (find-fragment forms path) + (or (for/or ((form forms)) + (if (and (pair? form) + (eq? (car form) 'def) + (pair? (cdr form)) + (eq? (cadr form) 'fragment) + (pair? (cddr form))) + (unquote-form (caddr form)) + #f)) + (error 'find-fragment "expected `(def fragment '(...))` in fragment file" path))) (def (entries spec) (if (and (pair? spec) (eq? (car spec) 'android-app)) (cdr spec) (error 'entries "expected android-app spec" spec))) +(def (fragment-entry? item) + (and (pair? item) + (eq? (car item) 'fragment) + (= (length item) 2) + (string? (cadr item)))) + +(def (client-entry? item) + (and (pair? item) + (eq? (car item) 'client) + (pair? (cdr item)) + (or (symbol? (cadr item)) (string? (cadr item))))) + +(def (client-name item) + (let ((name (cadr item))) + (let ((text (if (symbol? name) (symbol->string name) name))) + (unless (and (> (string-length text) 0) + (<= (string-length text) 64) + (every (lambda (ch) + (or (char-alphabetic? ch) + (char-numeric? ch) + (char=? ch #\-) + (char=? ch #\_))) + (string->list text))) + (error 'client "unsafe named client identifier" text)) + text))) + +(def (client-template-path generator-dir item) + (path-join generator-dir + "templates" + (string-append (client-name item) ".ss"))) + +(def (expand-fragments spec base-dir generator-dir) + (let ((items (entries spec))) + (cons 'android-app + (apply append + (map (lambda (item) + (cond + ((fragment-entry? item) + (let* ((path (resolve-local-source-path + base-dir (cadr item) '(".ss"))) + (fragment (find-fragment (read-all-forms path) path))) + fragment)) + ((client-entry? item) + (let* ((path (client-template-path generator-dir item)) + (fragment (find-fragment (read-all-forms path) path))) + fragment)) + (else (list item)))) + items))))) + (def (entry-value items key default) (let loop ((xs items)) (cond @@ -58,6 +113,26 @@ (cadar xs)) (else (loop (cdr xs)))))) +(def (entry-values items key) + (reverse + (let loop ((xs items) (out '())) + (cond + ((null? xs) out) + ((and (pair? (car xs)) + (eq? (caar xs) key) + (pair? (cdar xs))) + (loop (cdr xs) (cons (cadar xs) out))) + (else (loop (cdr xs) out)))))) + +(def (entry-forms items key) + (reverse + (let loop ((xs items) (out '())) + (cond + ((null? xs) out) + ((and (pair? (car xs)) (eq? (caar xs) key)) + (loop (cdr xs) (cons (car xs) out))) + (else (loop (cdr xs) out)))))) + (def (find-entry items key) (let loop ((xs items)) (cond @@ -65,20 +140,85 @@ ((and (pair? (car xs)) (eq? (caar xs) key)) (car xs)) (else (loop (cdr xs)))))) -(def (app-id spec) - (entry-value (entries spec) 'id "org.jerboa.generated")) - -(def (app-name spec) - (entry-value (entries spec) 'name "Jerboa Android")) - +(def (immutable-version value who) + (unless (and (string? value) + (> (string-length value) 0) + (<= (string-length value) 96) + (every (lambda (ch) + (or (char-alphabetic? ch) + (char-numeric? ch) + (memv ch '(#\. #\_ #\-)))) + (string->list value)) + (not (string-prefix? "latest." (string-downcase value))) + (not (string-suffix? "-snapshot" (string-downcase value)))) + (error who "dependency/tool version must be an immutable exact version" value)) + value) + +(def (immutable-dependency-coordinate value) + (unless (string? value) + (error 'dependency "dependency coordinate must be a string" value)) + (let ([parts (string-split value #\:)]) + (unless (and (= (length parts) 3) + (every (lambda (part) (> (string-length part) 0)) parts)) + (error 'dependency "expected exact group:name:version coordinate" value)) + (immutable-version (caddr parts) 'dependency) + value)) + +(def (bounded-integer value who minimum maximum) + (unless (and (integer? value) (<= minimum value maximum)) + (error who "integer is outside the supported range" value minimum maximum)) + value) + +(def (app-id spec) (entry-value (entries spec) 'id "org.jerboa.generated")) +(def (app-name spec) (entry-value (entries spec) 'name "Jerboa Android")) +(def (root-project-name spec) (entry-value (entries spec) 'root-name (app-name spec))) (def (compile-sdk spec) - (entry-value (entries spec) 'compile-sdk 36)) - + (bounded-integer (entry-value (entries spec) 'compile-sdk 35) 'compile-sdk 1 100)) +(def (build-tools-version spec) + (immutable-version + (entry-value (entries spec) 'build-tools-version "36.0.0") + 'build-tools-version)) (def (target-sdk spec) - (entry-value (entries spec) 'target-sdk (compile-sdk spec))) - + (bounded-integer + (entry-value (entries spec) 'target-sdk (compile-sdk spec)) + 'target-sdk 1 100)) (def (min-sdk spec) - (entry-value (entries spec) 'min-sdk 26)) + (bounded-integer (entry-value (entries spec) 'min-sdk 26) 'min-sdk 1 100)) +(def (version-code spec) + (bounded-integer (entry-value (entries spec) 'version-code 1) 'version-code 1 2147483647)) +(def (version-name spec) (entry-value (entries spec) 'version-name "0.1.0")) +(def (agp-version spec) + (immutable-version + (entry-value (entries spec) 'android-gradle-plugin "8.13.2") + 'android-gradle-plugin)) +(def (kotlin-version spec) + (immutable-version + (entry-value (entries spec) 'kotlin-version "2.0.21") + 'kotlin-version)) +(def (kotlin? spec) (entry-value (entries spec) 'kotlin #t)) +(def (jvm-toolchain spec) + (bounded-integer (entry-value (entries spec) 'jvm-toolchain 17) 'jvm-toolchain 8 30)) +(def (ndk-version spec) + (let ([version (entry-value (entries spec) 'ndk-version #f)]) + (if version (immutable-version version 'ndk-version) #f))) +(def (allow-backup spec) (entry-value (entries spec) 'allow-backup #f)) +(def (uses-cleartext-traffic spec) (entry-value (entries spec) 'uses-cleartext-traffic #f)) +(def (theme spec) (entry-value (entries spec) 'theme "@style/AppTheme")) +(def (permissions spec) (entry-values (entries spec) 'permission)) +(def (permission-forms spec) (entry-forms (entries spec) 'permission)) +(def (dependencies spec) (entry-values (entries spec) 'dependency)) +(def (abi-filters spec) (entry-values (entries spec) 'abi-filter)) +(def (asset-dirs spec) (entry-values (entries spec) 'asset-dir)) +(def (jni-lib-dirs spec) (entry-values (entries spec) 'jni-lib-dir)) +(def (kotlin-source-dirs spec) (entry-values (entries spec) 'kotlin-source-dir)) +(def (raw-files spec) (entry-forms (entries spec) 'file)) +(def (gradle-property-forms spec) (entry-forms (entries spec) 'gradle-property)) +(def (kotlin-file-entry? item) + (and (pair? item) + (or (eq? (car item) 'kotlin-file) + (eq? (car item) 'kotlin-file-lines)))) + +(def (kotlin-files spec) (filter kotlin-file-entry? (entries spec))) (def (main-screen spec) (or (find-entry (entries spec) 'screen) @@ -89,41 +229,50 @@ (cadr screen) (error 'screen-name "screen must have a symbol name" screen))) -(def (screen-items screen) - (cddr screen)) +(def (screen-items screen) (cddr screen)) -(def (state-entry? item) - (and (pair? item) (eq? (car item) 'state))) +(def (state-entry? item) (and (pair? item) (eq? (car item) 'state))) -(def (screen-states screen) - (filter state-entry? (screen-items screen))) +(def (screen-states screen) (filter state-entry? (screen-items screen))) (def (screen-layout screen) (or (for/or ((item (screen-items screen))) (if (state-entry? item) #f item)) '(column))) -(def (kotlin-ident sym) - (symbol->string sym)) +(def (kotlin-ident sym) (symbol->string sym)) -(def (kotlin-string s) +(def (escape-string s quote-ch) (with-output-to-string (lambda () - (display "\"") (for ((ch (in-string s))) (cond - ((char=? ch #\") (display "\\\"")) + ((char=? ch quote-ch) (display "\\") (display ch)) ((char=? ch #\\) (display "\\\\")) ((char=? ch #\newline) (display "\\n")) ((char=? ch #\tab) (display "\\t")) - (else (display ch)))) - (display "\"")))) + (else (display ch))))))) + +(def (kotlin-string s) (string-append "\"" (escape-string s #\") "\"")) + +(def (xml-escape s) + (with-output-to-string + (lambda () + (for ((ch (in-string s))) + (cond + ((char=? ch #\") (display """)) + ((char=? ch #\&) (display "&")) + ((char=? ch #\<) (display "<")) + ((char=? ch #\>) (display ">")) + (else (display ch))))))) + +(def (bool-string value) (if value "true" "false")) (def (kotlin-value value) (cond ((string? value) (kotlin-string value)) ((symbol? value) (kotlin-ident value)) - ((boolean? value) (if value "true" "false")) + ((boolean? value) (bool-string value)) ((number? value) (format "~a" value)) (else (error 'kotlin-value "unsupported literal" value)))) @@ -161,14 +310,9 @@ parts) " + "))) -(def (indent n) - (make-string n #\space)) - -(def (line n text) - (string-append (indent n) text "\n")) - -(def (add-view-prefix target) - (if (string-empty? target) "addView" (string-append target ".addView"))) +(def (indent n) (make-string n #\space)) +(def (line n text) (string-append (indent n) text "\n")) +(def (add-view-prefix target) (if (string-empty? target) "addView" (string-append target ".addView"))) (def (emit-view item level target context) (cond @@ -179,8 +323,7 @@ (line (+ level 4) "gravity = Gravity.CENTER") (line (+ level 4) "setPadding(dp(24), dp(24), dp(24), dp(24))") (apply string-append - (map (lambda (child) - (emit-view child (+ level 4) "" "this@MainActivity")) + (map (lambda (child) (emit-view child (+ level 4) "" "this@MainActivity")) (cdr item))) (line level "})"))) ((and (pair? item) (eq? (car item) 'row)) @@ -189,8 +332,7 @@ (line (+ level 4) "orientation = LinearLayout.HORIZONTAL") (line (+ level 4) "gravity = Gravity.CENTER") (apply string-append - (map (lambda (child) - (emit-view child (+ level 4) "" "this@MainActivity")) + (map (lambda (child) (emit-view child (+ level 4) "" "this@MainActivity")) (cdr item))) (line level "})"))) ((and (pair? item) (eq? (car item) 'text)) @@ -200,9 +342,15 @@ (line (+ level 4) "textSize = 28f") (line (+ level 4) "gravity = Gravity.CENTER") (line level "})"))) + ((and (pair? item) (eq? (car item) 'small-text)) + (string-append + (line level (string-append (add-view-prefix target) "(TextView(" context ").apply {")) + (line (+ level 4) (string-append "text = " (text-expr (cdr item)))) + (line (+ level 4) "textSize = 16f") + (line (+ level 4) "gravity = Gravity.CENTER") + (line level "})"))) ((and (pair? item) (eq? (car item) 'button) (>= (length item) 2)) - (let ((label (cadr item)) - (actions (cddr item))) + (let ((label (cadr item)) (actions (cddr item))) (string-append (line level (string-append (add-view-prefix target) "(Button(" context ").apply {")) (line (+ level 4) (string-append "text = " (kotlin-expr label))) @@ -217,8 +365,8 @@ (string-append (line level (string-append (add-view-prefix target) "(Space(" context ").apply {")) (line (+ level 4) (string-append "layoutParams = LinearLayout.LayoutParams(1, dp(" - (kotlin-expr (cadr item)) - "))")) + (kotlin-expr (cadr item)) + "))")) (line level "})"))) (else (error 'emit-view "unsupported view item" item)))) @@ -227,8 +375,7 @@ (cadr state) (error 'state-name "state entries are `(state name initial-value)`" state))) -(def (state-initial state) - (caddr state)) +(def (state-initial state) (caddr state)) (def (emit-state state) (string-append " private var " @@ -237,13 +384,31 @@ (kotlin-expr (state-initial state)) "\n")) +(def (java-identifier-component? value) + (and (string? value) + (> (string-length value) 0) + (<= (string-length value) 128) + (let ([first (string-ref value 0)]) + (or (char-alphabetic? first) (char=? first #\_))) + (every (lambda (ch) + (or (char-alphabetic? ch) + (char-numeric? ch) + (char=? ch #\_))) + (string->list value)))) + (def (package-path package-name) - (let loop ((parts (string-split package-name #\.)) - (path "")) + (unless (and (string? package-name) + (<= (string-length package-name) 255)) + (error 'package-path "invalid package name" package-name)) + (let ([parts (string-split package-name #\.)]) + (unless (and (pair? parts) (every java-identifier-component? parts)) + (error 'package-path "package components must be Java identifiers" + package-name)) + (let loop ((parts parts) (path "")) (cond ((null? parts) path) ((string-empty? path) (loop (cdr parts) (car parts))) - (else (loop (cdr parts) (path-join path (car parts))))))) + (else (loop (cdr parts) (path-join path (car parts)))))))) (def (main-activity-kt spec) (let* ((package-name (app-id spec)) @@ -288,6 +453,24 @@ " gradlePluginPortal()\n" " }\n" "}\n\n" + "// Keep the build's executable plugin classpath on reviewed, advisory-clean\n" + "// versions even when a plugin requests an older transitive dependency.\n" + "gradle.beforeProject {\n" + " buildscript.configurations.configureEach {\n" + " resolutionStrategy.eachDependency {\n" + " when (requested.group) {\n" + " \"io.netty\" -> useVersion(\"4.1.135.Final\")\n" + " \"org.bouncycastle\" -> useVersion(\"1.84\")\n" + " \"com.google.protobuf\" -> useVersion(\"3.25.5\")\n" + " }\n" + " when (\"${requested.group}:${requested.name}\") {\n" + " \"org.apache.commons:commons-compress\" -> useVersion(\"1.28.0\")\n" + " \"org.bitbucket.b_c:jose4j\" -> useVersion(\"0.9.6\")\n" + " \"org.jdom:jdom2\" -> useVersion(\"2.0.6.1\")\n" + " }\n" + " }\n" + " }\n" + "}\n\n" "dependencyResolutionManagement {\n" " repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)\n" " repositories {\n" @@ -295,40 +478,138 @@ " mavenCentral()\n" " }\n" "}\n\n" - "rootProject.name = " (kotlin-string (app-name spec)) "\n" + "rootProject.name = " (kotlin-string (root-project-name spec)) "\n" "include(\":app\")\n")) -(def (root-build-gradle) +(def (root-build-gradle spec) (string-append "plugins {\n" - " id(\"com.android.application\") version \"9.2.0\" apply false\n" + " id(\"com.android.application\") version " (kotlin-string (agp-version spec)) " apply false\n" + (if (kotlin? spec) + (string-append " id(\"org.jetbrains.kotlin.android\") version " + (kotlin-string (kotlin-version spec)) + " apply false\n") + "") "}\n")) +(def (emit-abi-filters filters) + (if (null? filters) + "" + (string-append + " ndk {\n" + (apply string-append + (map (lambda (abi) + (string-append " abiFilters += " (kotlin-string abi) "\n")) + filters)) + " }\n"))) + +(def (dependency-lines spec) + (let ((deps (dependencies spec))) + (if (null? deps) + "" + (string-append + "\n" + "dependencies {\n" + (apply string-append + (map (lambda (dep) + (string-append + " implementation(" + (kotlin-string (immutable-dependency-coordinate dep)) + ")\n")) + deps)) + "}\n")))) + +(def (gradle-property-line property-form) + (unless (and (= (length property-form) 3) + (string? (cadr property-form)) + (string? (caddr property-form))) + (error 'gradle-property "expected `(gradle-property key value)`" property-form)) + (string-append (cadr property-form) "=" (caddr property-form) "\n")) + +(def (gradle-properties-file spec) + (apply string-append (map gradle-property-line (gradle-property-forms spec)))) + (def (app-build-gradle spec) - (string-append - "plugins {\n" - " id(\"com.android.application\")\n" - "}\n\n" - "android {\n" - " namespace = " (kotlin-string (app-id spec)) "\n" - " compileSdk = " (format "~a" (compile-sdk spec)) "\n\n" - " defaultConfig {\n" - " applicationId = " (kotlin-string (app-id spec)) "\n" - " minSdk = " (format "~a" (min-sdk spec)) "\n" - " targetSdk = " (format "~a" (target-sdk spec)) "\n" - " versionCode = 1\n" - " versionName = \"0.1.0\"\n" - " }\n" - "}\n")) + (let ((ndk (ndk-version spec)) + (abis (abi-filters spec))) + (string-append + "plugins {\n" + " id(\"com.android.application\")\n" + (if (kotlin? spec) " id(\"org.jetbrains.kotlin.android\")\n" "") + "}\n\n" + "android {\n" + " namespace = " (kotlin-string (app-id spec)) "\n" + " compileSdk = " (format "~a" (compile-sdk spec)) "\n" + " buildToolsVersion = " (kotlin-string (build-tools-version spec)) "\n" + (if ndk (string-append " ndkVersion = " (kotlin-string ndk) "\n") "") + "\n" + " defaultConfig {\n" + " applicationId = " (kotlin-string (app-id spec)) "\n" + " minSdk = " (format "~a" (min-sdk spec)) "\n" + " targetSdk = " (format "~a" (target-sdk spec)) "\n" + " versionCode = " (format "~a" (version-code spec)) "\n" + " versionName = " (kotlin-string (version-name spec)) "\n" + (emit-abi-filters abis) + " }\n" + "\n" + " compileOptions {\n" + " sourceCompatibility = JavaVersion.VERSION_" (format "~a" (jvm-toolchain spec)) "\n" + " targetCompatibility = JavaVersion.VERSION_" (format "~a" (jvm-toolchain spec)) "\n" + " }\n" + "}\n" + (if (kotlin? spec) + (string-append "\n" + "kotlin {\n" + " jvmToolchain(" (format "~a" (jvm-toolchain spec)) ")\n" + "}\n") + "") + (dependency-lines spec)))) + +(def (permission-attr-value attrs key default) + (let loop ((xs attrs)) + (cond + ((null? xs) default) + ((and (pair? (car xs)) + (eq? (caar xs) key) + (pair? (cdar xs))) + (cadar xs))