diff options
Diffstat (limited to 'ci')
| -rwxr-xr-x | ci/build.sh | 12 | ||||
| -rwxr-xr-x | ci/release.sh | 36 | ||||
| -rwxr-xr-x | ci/setup-toolchain.sh | 73 | ||||
| -rwxr-xr-x | ci/test.sh | 29 | ||||
| -rw-r--r-- | ci/testsshd/go.mod | 7 | ||||
| -rw-r--r-- | ci/testsshd/go.sum | 6 | ||||
| -rw-r--r-- | ci/testsshd/main.go | 149 | ||||
| -rwxr-xr-x | ci/verify-repro.sh | 23 |
8 files changed, 335 insertions, 0 deletions
diff --git a/ci/build.sh b/ci/build.sh new file mode 100755 index 0000000..5afbfd4 --- /dev/null +++ b/ci/build.sh @@ -0,0 +1,12 @@ +#!/bin/sh +# Full build: native executables (rsync + rsh via the NDK), then the APK. +# Requires the pinned toolchain (see versions): Android SDK + NDK, a JDK, Go. +set -eu + +root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) + +make -C "$root" native +make -C "$root" app + +echo "ci: APK at app/build/outputs/apk/release/" +ls -l "$root"/app/build/outputs/apk/release/*.apk 2>/dev/null || true diff --git a/ci/release.sh b/ci/release.sh new file mode 100755 index 0000000..cec2b42 --- /dev/null +++ b/ci/release.sh @@ -0,0 +1,36 @@ +#!/bin/sh +# Publish a signed release APK to a Codeberg (Forgejo) release. Run on a tag, +# after ci/build.sh has produced a signed app-release.apk (signing happens when +# keystore.properties is present). +# +# Environment: +# CODEBERG_TOKEN Forgejo API token (secret) +# CODEBERG_REPO owner/name, e.g. lena/rsend +# TAG release tag (defaults to CI_COMMIT_TAG) +# CODEBERG_URL base URL (defaults to https://codeberg.org) +set -eu + +: "${CODEBERG_TOKEN:?set CODEBERG_TOKEN}" +: "${CODEBERG_REPO:?set CODEBERG_REPO as owner/name}" +tag=${TAG:-${CI_COMMIT_TAG:?set TAG or CI_COMMIT_TAG}} +base=${CODEBERG_URL:-https://codeberg.org} +apk=app/build/outputs/apk/release/app-release.apk + +[ -f "$apk" ] || { echo "release: $apk not found; run ci/build.sh first" >&2; exit 1; } + +api="$base/api/v1/repos/$CODEBERG_REPO" + +# Create the release for the tag and capture its id. +id=$(curl -fsS -X POST "$api/releases" \ + -H "Authorization: token $CODEBERG_TOKEN" \ + -H "Content-Type: application/json" \ + -d "{\"tag_name\":\"$tag\",\"name\":\"$tag\"}" \ + | sed -n 's/.*"id":\([0-9][0-9]*\).*/\1/p' | head -1) +[ -n "$id" ] || { echo "release: could not create release for $tag" >&2; exit 1; } + +# Upload the APK as a release asset. +curl -fsS -X POST "$api/releases/$id/assets?name=rsend-$tag.apk" \ + -H "Authorization: token $CODEBERG_TOKEN" \ + -F "attachment=@$apk" >/dev/null + +echo "release: uploaded rsend-$tag.apk to $CODEBERG_REPO ($tag)" diff --git a/ci/setup-toolchain.sh b/ci/setup-toolchain.sh new file mode 100755 index 0000000..c832708 --- /dev/null +++ b/ci/setup-toolchain.sh @@ -0,0 +1,73 @@ +#!/bin/sh +# Provision the pinned Android build toolchain into TOOLCHAIN_DIR (default +# $HOME/toolchains): JDK 17, Android cmdline-tools, SDK platform, build-tools, +# NDK, and Gradle. Idempotent: re-running only fills what is missing. Writes an +# env.sh to source before building. This is what CI runs to get a build host. +set -eu + +root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +. "$root/versions" + +dir=${TOOLCHAIN_DIR:-$HOME/toolchains} +sdk="$dir/android-sdk" +mkdir -p "$dir/dl" + +# JDK 17 (Temurin). +if [ ! -x "$dir/jdk17/bin/java" ]; then + echo "setup: JDK $JDK_VERSION" + curl -fsSL "https://api.adoptium.net/v3/binary/latest/${JDK_VERSION}/ga/linux/x64/jdk/hotspot/normal/eclipse" -o "$dir/dl/jdk.tar.gz" + mkdir -p "$dir/jdk17" + tar xzf "$dir/dl/jdk.tar.gz" -C "$dir/jdk17" --strip-components=1 +fi +export JAVA_HOME="$dir/jdk17" +export PATH="$JAVA_HOME/bin:$PATH" + +# Android cmdline-tools. +if [ ! -x "$sdk/cmdline-tools/latest/bin/sdkmanager" ]; then + echo "setup: cmdline-tools $ANDROID_CMDLINE_TOOLS" + curl -fsSL "https://dl.google.com/android/repository/commandlinetools-linux-${ANDROID_CMDLINE_TOOLS}_latest.zip" -o "$dir/dl/clt.zip" + rm -rf "$dir/dl/clt" + python3 -m zipfile -e "$dir/dl/clt.zip" "$dir/dl/clt" + mkdir -p "$sdk/cmdline-tools" + rm -rf "$sdk/cmdline-tools/latest" + mv "$dir/dl/clt/cmdline-tools" "$sdk/cmdline-tools/latest" + chmod +x "$sdk/cmdline-tools/latest/bin/"* +fi + +# SDK packages. +echo "setup: sdk packages" +yes | "$sdk/cmdline-tools/latest/bin/sdkmanager" --licenses >/dev/null 2>&1 || true +"$sdk/cmdline-tools/latest/bin/sdkmanager" \ + "platform-tools" \ + "platforms;android-${ANDROID_PLATFORM}" \ + "build-tools;${ANDROID_BUILD_TOOLS}" \ + "ndk;${ANDROID_NDK}" >/dev/null + +# Gradle. +if [ ! -x "$dir/gradle-${GRADLE_VERSION}/bin/gradle" ]; then + echo "setup: Gradle $GRADLE_VERSION" + curl -fsSL "https://services.gradle.org/distributions/gradle-${GRADLE_VERSION}-bin.zip" -o "$dir/dl/gradle.zip" + python3 -m zipfile -e "$dir/dl/gradle.zip" "$dir" + chmod +x "$dir/gradle-${GRADLE_VERSION}/bin/gradle" +fi + +# Go (pinned; builds rsh). +if [ ! -x "$dir/go/bin/go" ]; then + echo "setup: Go $GO_VERSION" + curl -fsSL "https://go.dev/dl/go${GO_VERSION}.linux-amd64.tar.gz" -o "$dir/dl/go.tar.gz" + rm -rf "$dir/go" + tar xzf "$dir/dl/go.tar.gz" -C "$dir" +fi + +# Emit the env to source before building. +cat > "$dir/env.sh" <<EOF +export JAVA_HOME="$dir/jdk17" +export GOROOT="$dir/go" +export ANDROID_HOME="$sdk" +export ANDROID_SDK_ROOT="$sdk" +export ANDROID_NDK_HOME="$sdk/ndk/${ANDROID_NDK}" +export GRADLE_HOME="$dir/gradle-${GRADLE_VERSION}" +export PATH="\$JAVA_HOME/bin:$dir/go/bin:\$GRADLE_HOME/bin:$sdk/cmdline-tools/latest/bin:$sdk/platform-tools:\$PATH" +EOF + +echo "setup: done; source $dir/env.sh" diff --git a/ci/test.sh b/ci/test.sh new file mode 100755 index 0000000..8e8a3a4 --- /dev/null +++ b/ci/test.sh @@ -0,0 +1,29 @@ +#!/bin/sh +# Host-verifiable checks: pinned source checksum, Go formatting, vet, and tests +# (including the real-rsync-through-rsh end-to-end). Needs Go and rsync; no +# Android toolchain. This is what runs on every push. +set -eu + +root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +. "$root/versions" + +if [ -f "$root/rsync/rsync-${RSYNC_VERSION}.tar.gz" ]; then + echo "ci: verifying pinned rsync checksum" + ( cd "$root/rsync" && sha256sum -c "rsync-${RSYNC_VERSION}.tar.gz.sha256" ) +fi + +echo "ci: gofmt" +unformatted=$(gofmt -l "$root/rsh") +if [ -n "$unformatted" ]; then + echo "ci: gofmt needed for:" >&2 + echo "$unformatted" >&2 + exit 1 +fi + +echo "ci: go vet" +( cd "$root/rsh" && go vet ./... ) + +echo "ci: go test" +( cd "$root/rsh" && go test ./... ) + +echo "ci: ok" diff --git a/ci/testsshd/go.mod b/ci/testsshd/go.mod new file mode 100644 index 0000000..88ccdc7 --- /dev/null +++ b/ci/testsshd/go.mod @@ -0,0 +1,7 @@ +module rsend/ci/testsshd + +go 1.25.0 + +require golang.org/x/crypto v0.53.0 + +require golang.org/x/sys v0.46.0 // indirect diff --git a/ci/testsshd/go.sum b/ci/testsshd/go.sum new file mode 100644 index 0000000..68cadc1 --- /dev/null +++ b/ci/testsshd/go.sum @@ -0,0 +1,6 @@ +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= +golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= diff --git a/ci/testsshd/main.go b/ci/testsshd/main.go new file mode 100644 index 0000000..19a53ac --- /dev/null +++ b/ci/testsshd/main.go @@ -0,0 +1,149 @@ +// testsshd is a minimal SSH server for rsend integration tests: one host key, +// an authorized_keys allowlist, and command execution via /bin/sh (so it runs +// the remote rsync). It is not a product component; it stands in for a real +// sshd so the full push path can be exercised on a device or in CI. +package main + +import ( + "flag" + "io" + "log" + "net" + "os" + "os/exec" + + "golang.org/x/crypto/ssh" +) + +func main() { + addr := flag.String("addr", "127.0.0.1:2222", "listen address") + hostKeyPath := flag.String("hostkey", "", "OpenSSH private host key (required)") + authPath := flag.String("authorized", "", "authorized_keys file (required)") + flag.Parse() + + if *hostKeyPath == "" || *authPath == "" { + log.Fatal("testsshd: -hostkey and -authorized are required") + } + + hostKey := mustSigner(*hostKeyPath) + allowed := mustAuthorized(*authPath) + + cfg := &ssh.ServerConfig{ + PublicKeyCallback: func(_ ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) { + if allowed[string(key.Marshal())] { + return &ssh.Permissions{}, nil + } + return nil, io.EOF + }, + } + cfg.AddHostKey(hostKey) + + ln, err := net.Listen("tcp", *addr) + if err != nil { + log.Fatalf("testsshd: listen: %v", err) + } + log.Printf("testsshd: listening on %s", ln.Addr()) + for { + c, err := ln.Accept() + if err != nil { + log.Fatalf("testsshd: accept: %v", err) + } + go handle(c, cfg) + } +} + +func mustSigner(path string) ssh.Signer { + b, err := os.ReadFile(path) + if err != nil { + log.Fatalf("testsshd: read host key: %v", err) + } + s, err := ssh.ParsePrivateKey(b) + if err != nil { + log.Fatalf("testsshd: parse host key: %v", err) + } + return s +} + +func mustAuthorized(path string) map[string]bool { + b, err := os.ReadFile(path) + if err != nil { + log.Fatalf("testsshd: read authorized_keys: %v", err) + } + m := map[string]bool{} + for len(b) > 0 { + key, _, _, rest, err := ssh.ParseAuthorizedKey(b) + if err != nil { + break + } + m[string(key.Marshal())] = true + b = rest + } + if len(m) == 0 { + log.Fatal("testsshd: no authorized keys") + } + return m +} + +func handle(nConn net.Conn, cfg *ssh.ServerConfig) { + conn, chans, reqs, err := ssh.NewServerConn(nConn, cfg) + if err != nil { + return + } + defer conn.Close() + go ssh.DiscardRequests(reqs) + for nc := range chans { + if nc.ChannelType() != "session" { + nc.Reject(ssh.UnknownChannelType, "only session") + continue + } + ch, requests, err := nc.Accept() + if err != nil { + return + } + go session(ch, requests) + } +} + +func session(ch ssh.Channel, requests <-chan *ssh.Request) { + for req := range requests { + if req.Type != "exec" { + if req.WantReply { + req.Reply(false, nil) + } + continue + } + var payload struct{ Command string } + ssh.Unmarshal(req.Payload, &payload) + if req.WantReply { + req.Reply(true, nil) + } + code := run(payload.Command, ch) + ch.SendRequest("exit-status", false, ssh.Marshal(struct{ Status uint32 }{uint32(code)})) + ch.Close() + return + } +} + +func run(cmd string, ch ssh.Channel) int { + c := exec.Command("/bin/sh", "-c", cmd) + c.Stdout = ch + c.Stderr = ch.Stderr() + in, err := c.StdinPipe() + if err != nil { + return 1 + } + if err := c.Start(); err != nil { + return 1 + } + go func() { + io.Copy(in, ch) + in.Close() + }() + if err := c.Wait(); err != nil { + if ee, ok := err.(*exec.ExitError); ok { + return ee.ExitCode() + } + return 1 + } + return 0 +} diff --git a/ci/verify-repro.sh b/ci/verify-repro.sh new file mode 100755 index 0000000..d412713 --- /dev/null +++ b/ci/verify-repro.sh @@ -0,0 +1,23 @@ +#!/bin/sh +# Build the native libs twice and diff them, catching nondeterminism early. +# Requires the Android NDK and Go. +set -eu + +root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +a=$(mktemp -d) +b=$(mktemp -d) +trap 'rm -rf "$a" "$b"' EXIT + +make -C "$root" native +cp -r "$root/app/src/main/jniLibs" "$a/jniLibs" + +rm -rf "$root/app/src/main/jniLibs" "$root/rsync/work" "$root/out" +make -C "$root" native +cp -r "$root/app/src/main/jniLibs" "$b/jniLibs" + +if diff -r "$a/jniLibs" "$b/jniLibs"; then + echo "verify-repro: native libs are bit-identical" +else + echo "verify-repro: NONDETERMINISM detected" >&2 + exit 1 +fi |