forR8(String mainClass) {
+ return rules(mainClass);
+ }
+}
diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/Cn1NameFactory.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/Cn1NameFactory.java
new file mode 100644
index 00000000000..7a86bba3cf3
--- /dev/null
+++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/Cn1NameFactory.java
@@ -0,0 +1,124 @@
+/*
+ * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved.
+ * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
+ * This code is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License version 2 only, as
+ * published by the Free Software Foundation. Codename One designates this
+ * particular file as subject to the "Classpath" exception as provided
+ * by Oracle in the LICENSE file that accompanied this code.
+ *
+ * This code is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+ * version 2 for more details (a copy is included in the LICENSE file that
+ * accompanied this code).
+ *
+ * You should have received a copy of the GNU General Public License version
+ * 2 along with this work; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
+ *
+ * Please contact Codename One through http://www.codenameone.com/ if you
+ * need additional information or have any questions.
+ */
+package com.codename1.hardening;
+
+import java.io.BufferedWriter;
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.OutputStreamWriter;
+import java.io.Writer;
+import java.nio.charset.Charset;
+
+/**
+ * Generates the obfuscation dictionaries ProGuard renames from. Every generated
+ * name starts with a distinctive prefix, is at least six characters, is lower-case
+ * and never contains an underscore.
+ *
+ * This is not cosmetic -- it is the fix for a ParparVM build-killer. The
+ * translator decides whether a class is reachable from native code by asking
+ * whether the class name is a substring of any identifier in the native
+ * sources ({@code BytecodeMethod.isMethodUsedByNative} /
+ * {@code NativeSymbolIndex}). ProGuard's default names ({@code a}, {@code b},
+ * {@code aa}) are substrings of almost every native identifier, so with default
+ * names nothing is ever culled: the iOS/Windows/Linux/JS binaries balloon and the
+ * translator runs out of heap. A {@code zq}-prefixed six-plus-character name is a
+ * substring of nothing in the native sources, so culling works normally.
+ *
+ *
The dictionary is also sized so ProGuard never exhausts it and falls back to
+ * its own short-name generator, which would reintroduce the pathology for the
+ * overflow names.
+ */
+public final class Cn1NameFactory {
+
+ /**
+ * The name prefix. Chosen so it cannot occur inside a CN1 native identifier
+ * (which are {@code package_Class_method}-mangled Java names and C runtime
+ * symbols); ASCII, lower-case, underscore-free.
+ */
+ static final String PREFIX = "zq";
+
+ private static final char[] ALPHABET = "abcdefghijklmnopqrstuvwxyz".toCharArray();
+ private static final int MIN_BODY_WIDTH = 4; // PREFIX(2) + 4 => 6-char minimum
+
+ private Cn1NameFactory() {
+ }
+
+ /** The nth distinctive name: {@code zq} + a fixed-width base-26 body, e.g. {@code zqaaaa}. */
+ public static String word(int index) {
+ if (index < 0) {
+ throw new IllegalArgumentException("index < 0");
+ }
+ StringBuilder body = new StringBuilder();
+ int n = index;
+ do {
+ body.append(ALPHABET[n % 26]);
+ n /= 26;
+ } while (n > 0);
+ while (body.length() < MIN_BODY_WIDTH) {
+ body.append('a');
+ }
+ return PREFIX + body.reverse().toString();
+ }
+
+ /**
+ * Writes a dictionary of {@code count} distinct names to {@code out}. A build feeds the same
+ * file as the class, member and package obfuscation dictionary; sizing it above the number of
+ * names any one scope needs guarantees ProGuard never falls back to short names. The
+ * {@code seed} shifts the starting word so that different seeds (or build keys) yield different
+ * name assignments -- hence different mappings -- while the same seed reproduces them exactly.
+ */
+ public static void writeDictionary(File out, int count, int seed) throws IOException {
+ int safeCount = Math.max(count, 1);
+ // A stable, non-negative offset from the seed; the word() indexing stays injective, so the
+ // offset never introduces collisions.
+ int offset = (seed & 0x7fffffff) % 1000000;
+ FileOutputStream fo = new FileOutputStream(out);
+ try {
+ Writer w = new BufferedWriter(new OutputStreamWriter(fo, Charset.forName("UTF-8")));
+ for (int i = 0; i < safeCount; i++) {
+ w.write(word(offset + i));
+ w.write('\n');
+ }
+ w.flush();
+ } finally {
+ fo.close();
+ }
+ }
+
+ /**
+ * The dictionary size to use for a jar with {@code classCount} classes and a largest member naming
+ * scope of {@code maxMemberNamingScope} (the caller computes this: a class's field count, or the
+ * number of same-descriptor methods across an inheritance hierarchy, both of which need collision-free
+ * names). The one dictionary feeds ProGuard's class, member AND package obfuscation, so it must exceed
+ * the LARGEST naming scope: the global class count, or that member scope (a generated hierarchy can
+ * have tens of thousands of same-descriptor methods, all needing distinct names). Sizing to only the
+ * class count would let a member-heavy hierarchy exhaust the dictionary and drop ProGuard back to its
+ * {@code a}/{@code b} short names, reintroducing the ParparVM native-scan pathology this class exists
+ * to avoid. Kept comfortably above that maximum with a floor for small apps.
+ */
+ public static int dictionarySizeFor(int classCount, int maxMemberNamingScope) {
+ int largestScope = Math.max(classCount, maxMemberNamingScope);
+ return Math.max(50000, largestScope * 4);
+ }
+}
diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/ControlFlowTransform.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/ControlFlowTransform.java
new file mode 100644
index 00000000000..e75dfd0fd35
--- /dev/null
+++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/ControlFlowTransform.java
@@ -0,0 +1,292 @@
+/*
+ * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved.
+ * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
+ * This code is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License version 2 only, as
+ * published by the Free Software Foundation. Codename One designates this
+ * particular file as subject to the "Classpath" exception as provided
+ * by Oracle in the LICENSE file that accompanied this code.
+ *
+ * This code is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+ * version 2 for more details (a copy is included in the LICENSE file that
+ * accompanied this code).
+ *
+ * You should have received a copy of the GNU General Public License version
+ * 2 along with this work; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
+ *
+ * Please contact Codename One through http://www.codenameone.com/ if you
+ * need additional information or have any questions.
+ */
+package com.codename1.hardening;
+
+import org.objectweb.asm.ClassReader;
+import org.objectweb.asm.ClassWriter;
+import org.objectweb.asm.Opcodes;
+import org.objectweb.asm.tree.ClassNode;
+import org.objectweb.asm.tree.FieldInsnNode;
+import org.objectweb.asm.tree.FieldNode;
+import org.objectweb.asm.tree.InsnList;
+import org.objectweb.asm.tree.InsnNode;
+import org.objectweb.asm.tree.JumpInsnNode;
+import org.objectweb.asm.tree.LabelNode;
+import org.objectweb.asm.tree.LdcInsnNode;
+import org.objectweb.asm.tree.MethodInsnNode;
+import org.objectweb.asm.tree.MethodNode;
+import org.objectweb.asm.tree.TypeInsnNode;
+
+/**
+ * Inserts an opaque predicate at the entry of each real method: a branch guarded by
+ * a value the renamer/decompiler cannot prove, so the disassembly grows a dead
+ * arm that a reader must rule out by hand. The guard reads a synthetic per-class
+ * field initialized at class-load from a non-constant runtime value
+ * ({@code System.getProperty("java.home").length()}, always positive), so neither
+ * javac, R8 nor a decompiler can fold it away.
+ *
+ *
This is deliberately conservative -- an entry guard, not control-flow
+ * flattening. Flattening fights the ParparVM devirtualizer and the arithmetic
+ * reducer, breaks the fused-constructor shape analysis, and must never touch
+ * {@code } or {@code @Fused} classes; the engine keeps it off the native
+ * ports entirely (see {@link HardeningEngine}). The behaviour is a strict no-op:
+ * the dead arm only ever throws and is never reached.
+ */
+public final class ControlFlowTransform {
+
+ static final String GUARD_FIELD = "zq$cf";
+ static final String GUARD_DESC = "I";
+
+ /** Widest encoding of one entry guard (GETSTATIC, IFGT, NEW, DUP, INVOKESPECIAL, ATHROW). */
+ private static final int GUARD_BYTES = 16;
+ /** Widest encoding of the guard-field setup prepended to {@code } (2 calls + PUTSTATIC). */
+ private static final int GUARD_INIT_BYTES = 16;
+ /**
+ * Conservative constant-pool entries the guard adds: the guard field (Utf8/Fieldref/NameAndType),
+ * the {@code Runtime}/{@code getRuntime}/{@code availableProcessors} references, and the
+ * {@code RuntimeException} constructor reference. Fixed per class, so a class whose pool is already
+ * near the 65535-entry limit cannot be guarded at all.
+ */
+ private static final int GUARD_POOL_OVERHEAD = 32;
+
+ private final ClassLoader hierarchy;
+ private final int intensity;
+ private int guardedMethods;
+ private int oversizedMethods;
+
+ public ControlFlowTransform() {
+ this(null, 1);
+ }
+
+ public ControlFlowTransform(ClassLoader hierarchy) {
+ this(hierarchy, 1);
+ }
+
+ /**
+ * @param hierarchy a classloader over the (renamed) input classes plus the library jars, used
+ * for stack-map frame computation; may be {@code null} in tests
+ * @param intensity how many opaque-predicate guards to insert per method (paranoid uses 2)
+ */
+ public ControlFlowTransform(ClassLoader hierarchy, int intensity) {
+ this.hierarchy = hierarchy;
+ this.intensity = Math.max(1, intensity);
+ }
+
+ public int getGuardedMethods() {
+ return guardedMethods;
+ }
+
+ /**
+ * Methods left unguarded because they are already so close to the 65,535-byte method limit that
+ * prepending the guard(s) would overflow them. Skipped rather than aborting the build; reported so
+ * a paranoid build knows a large generated method kept its plain control flow.
+ */
+ public int getOversizedMethods() {
+ return oversizedMethods;
+ }
+
+ public byte[] transform(byte[] classBytes) {
+ ClassNode cn = new ClassNode();
+ ClassReader reader = new ClassReader(classBytes);
+ reader.accept(cn, ClassReader.SKIP_FRAMES);
+
+ if ((cn.access & Opcodes.ACC_INTERFACE) != 0) {
+ return classBytes;
+ }
+ // The guard adds a field and references (Runtime, RuntimeException, the guard field) at a fixed
+ // constant-pool cost. If the class's pool is already so full it cannot fit that overhead, the
+ // class cannot be guarded without ClassTooLargeException. Skip it and report the methods that
+ // stay plain rather than aborting the entire hardened build.
+ if (reader.getItemCount() + GUARD_POOL_OVERHEAD > MethodSize.SAFE_POOL_ITEMS) {
+ oversizedMethods += countGuardable(cn);
+ return classBytes;
+ }
+ // Pick a guard field name that collides with no existing member, so a class that happens to
+ // declare a zq$cf field (reachable on Android, where the engine doesn't rename first, or in a
+ // pre-obfuscated dependency) is still guarded instead of being returned unchanged on the false
+ // assumption that the collision means it was already transformed.
+ String guardField = resolveGuardField(cn);
+
+ // The guards read a field initialized in ; if is already near the method limit
+ // the setup cannot be added, and an uninitialized field is 0 -- which makes every guard take its
+ // dead (throwing) arm. So the whole class cannot be guarded then: skip it (reporting the methods
+ // that stay plain) rather than corrupt behaviour or abort the build with MethodTooLargeException.
+ if (MethodSize.estimateBytes(findClinit(cn)) + GUARD_INIT_BYTES > MethodSize.SAFE_LIMIT) {
+ oversizedMethods += countGuardable(cn);
+ return classBytes;
+ }
+
+ boolean changed = false;
+ if (cn.methods != null) {
+ for (MethodNode mn : cn.methods) {
+ if (!isGuardable(mn)) {
+ continue;
+ }
+ // A generated method can already be near the 65,535-byte limit; prepending guards would
+ // overflow it and make ASM abort the whole build. Skip (and report) such a method rather
+ // than fail on a valid input class.
+ if (MethodSize.estimateBytes(mn) + GUARD_BYTES * intensity > MethodSize.SAFE_LIMIT) {
+ oversizedMethods++;
+ continue;
+ }
+ for (int i = 0; i < intensity; i++) {
+ prependGuard(cn, mn, guardField);
+ }
+ guardedMethods++;
+ changed = true;
+ }
+ }
+ if (!changed) {
+ return classBytes;
+ }
+
+ addGuardField(cn, guardField);
+ initGuardField(cn, guardField);
+
+ FrameClassWriter cw = new FrameClassWriter(ClassWriter.COMPUTE_MAXS | ClassWriter.COMPUTE_FRAMES, hierarchy);
+ cn.accept(cw);
+ if (cw.isHierarchyIncomplete()) {
+ // A frame merge collapsed to Object because a supertype is absent from the supplied jars, so
+ // the recomputed StackMapTable may be too weak and fail on-device verification. Ship this class
+ // UNHARDENED (original valid frames) rather than a possibly-invalid one. Control flow leaves
+ // string literals untouched, so no jar-wide literal exclusion is needed here. Reset the guard
+ // counts: no guard is actually emitted for a discarded class, and a stale count would let the
+ // engine advertise controlFlow and stamp cn1.hardened=true for byte-unmodified output.
+ guardedMethods = 0;
+ oversizedMethods = 0;
+ return classBytes;
+ }
+ return cw.toByteArray();
+ }
+
+ private static MethodNode findClinit(ClassNode cn) {
+ if (cn.methods != null) {
+ for (MethodNode mn : cn.methods) {
+ if ("".equals(mn.name) && "()V".equals(mn.desc)) {
+ return mn;
+ }
+ }
+ }
+ return null;
+ }
+
+ private int countGuardable(ClassNode cn) {
+ int n = 0;
+ if (cn.methods != null) {
+ for (MethodNode mn : cn.methods) {
+ if (isGuardable(mn)) {
+ n++;
+ }
+ }
+ }
+ return n;
+ }
+
+ private boolean isGuardable(MethodNode mn) {
+ if (mn.instructions == null || mn.instructions.size() == 0) {
+ return false;
+ }
+ if ((mn.access & (Opcodes.ACC_ABSTRACT | Opcodes.ACC_NATIVE)) != 0) {
+ return false;
+ }
+ // A guard before super()/this() in a constructor, or before a static field
+ // set in , is unsafe. Leave both alone.
+ if ("".equals(mn.name) || "".equals(mn.name)) {
+ return false;
+ }
+ return true;
+ }
+
+ private void prependGuard(ClassNode cn, MethodNode mn, String guardField) {
+ InsnList pre = new InsnList();
+ LabelNode ok = new LabelNode();
+ pre.add(new FieldInsnNode(Opcodes.GETSTATIC, cn.name, guardField, GUARD_DESC));
+ // if (zq$cf > 0) goto ok; -- always taken at runtime, unprovable statically.
+ pre.add(new JumpInsnNode(Opcodes.IFGT, ok));
+ // dead arm: throw new RuntimeException(); -- never reached.
+ pre.add(new TypeInsnNode(Opcodes.NEW, "java/lang/RuntimeException"));
+ pre.add(new InsnNode(Opcodes.DUP));
+ pre.add(new MethodInsnNode(Opcodes.INVOKESPECIAL, "java/lang/RuntimeException", "", "()V", false));
+ pre.add(new InsnNode(Opcodes.ATHROW));
+ pre.add(ok);
+ mn.instructions.insert(pre);
+ }
+
+ /** A guard field name not already declared by {@code cn} (lengthens the suffix until free). */
+ private String resolveGuardField(ClassNode cn) {
+ String name = GUARD_FIELD;
+ if (cn.fields != null) {
+ boolean clash = true;
+ while (clash) {
+ clash = false;
+ for (FieldNode fn : cn.fields) {
+ if (name.equals(fn.name)) {
+ name = name + "$";
+ clash = true;
+ break;
+ }
+ }
+ }
+ }
+ return name;
+ }
+
+ private void addGuardField(ClassNode cn, String guardField) {
+ if (cn.fields == null) {
+ cn.fields = new java.util.ArrayList();
+ }
+ cn.fields.add(new FieldNode(Opcodes.ACC_PRIVATE | Opcodes.ACC_STATIC | Opcodes.ACC_SYNTHETIC,
+ guardField, GUARD_DESC, null, null));
+ }
+
+ private void initGuardField(ClassNode cn, String guardField) {
+ InsnList init = new InsnList();
+ // zq$cf = Runtime.getRuntime().availableProcessors(); -- contractually >= 1 on every JVM,
+ // and a runtime call the optimizer/decompiler cannot fold, so the guard is always taken and
+ // can neither NPE nor (unlike a possibly-empty system property) collapse to a zero value.
+ init.add(new MethodInsnNode(Opcodes.INVOKESTATIC, "java/lang/Runtime", "getRuntime",
+ "()Ljava/lang/Runtime;", false));
+ init.add(new MethodInsnNode(Opcodes.INVOKEVIRTUAL, "java/lang/Runtime", "availableProcessors",
+ "()I", false));
+ init.add(new FieldInsnNode(Opcodes.PUTSTATIC, cn.name, guardField, GUARD_DESC));
+
+ MethodNode clinit = null;
+ if (cn.methods != null) {
+ for (MethodNode mn : cn.methods) {
+ if ("".equals(mn.name) && "()V".equals(mn.desc)) {
+ clinit = mn;
+ break;
+ }
+ }
+ }
+ if (clinit == null) {
+ clinit = new MethodNode(Opcodes.ASM9, Opcodes.ACC_STATIC, "", "()V", null, null);
+ clinit.instructions = new InsnList();
+ clinit.instructions.add(init);
+ clinit.instructions.add(new InsnNode(Opcodes.RETURN));
+ cn.methods.add(clinit);
+ } else {
+ clinit.instructions.insert(init);
+ }
+ }
+}
diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/FrameClassWriter.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/FrameClassWriter.java
new file mode 100644
index 00000000000..a1d6242c252
--- /dev/null
+++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/FrameClassWriter.java
@@ -0,0 +1,270 @@
+/*
+ * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved.
+ * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
+ * This code is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License version 2 only, as
+ * published by the Free Software Foundation. Codename One designates this
+ * particular file as subject to the "Classpath" exception as provided
+ * by Oracle in the LICENSE file that accompanied this code.
+ *
+ * This code is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+ * version 2 for more details (a copy is included in the LICENSE file that
+ * accompanied this code).
+ *
+ * You should have received a copy of the GNU General Public License version
+ * 2 along with this work; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
+ *
+ * Please contact Codename One through http://www.codenameone.com/ if you
+ * need additional information or have any questions.
+ */
+package com.codename1.hardening;
+
+import java.io.InputStream;
+import java.util.ArrayDeque;
+import java.util.Deque;
+import java.util.HashSet;
+import java.util.LinkedHashSet;
+import java.util.Set;
+import org.objectweb.asm.ClassReader;
+import org.objectweb.asm.ClassWriter;
+import org.objectweb.asm.Opcodes;
+
+/**
+ * A {@link ClassWriter} that resolves the class hierarchy from the application and
+ * library jars instead of the engine's own classloader.
+ *
+ * {@code COMPUTE_FRAMES} has to find the common superclass of two reference types
+ * at a control-flow join, and ASM's default implementation does that by loading the
+ * types through {@code getClassLoader()}. The engine runs as {@code java -jar
+ * cn1-hardening.jar}, so the application classes and the supplied library jars are
+ * not on that classloader; the default resolver would then fail with a missing-type
+ * exception and abort hardening on any class with a merge between application types.
+ * This writer is given a classloader built over the (renamed) input classes plus the
+ * library jars.
+ *
+ *
Resolution has two stages. First it tries to LOAD the two types (precise when
+ * both, and their supertypes, are available). If loading throws -- typically because
+ * a shared supertype is supplied only by the target platform and is absent from the
+ * supplied jars -- it resolves the hierarchy from the class BYTES instead: reading the
+ * {@code super_class} name of each type does not require loading the (absent) supertype,
+ * so a real common supertype such as {@code Base} is still found. Only when the bytes
+ * cannot be read either does it fall back to {@code java/lang/Object}. Collapsing to
+ * {@code Object} too eagerly is not merely imprecise: if the merged value is then used
+ * where {@code Base} is expected, {@code Object} is not assignable to {@code Base} and
+ * the generated {@code StackMapTable} fails verification, rejecting a valid application.
+ */
+public final class FrameClassWriter extends ClassWriter {
+
+ private final ClassLoader hierarchy;
+ private boolean hierarchyIncomplete;
+
+ public FrameClassWriter(int flags, ClassLoader hierarchy) {
+ super(flags);
+ this.hierarchy = hierarchy;
+ }
+
+ /**
+ * True when a frame merge had to collapse to {@code java/lang/Object} because the type hierarchy was
+ * INCOMPLETE -- a supertype on the path to the real common base could not be read (absent from the
+ * supplied jars), not because the two types genuinely share only {@code Object}. In that case the
+ * recomputed {@code Object} stack-map type may be weaker than the type the target expects (e.g. a
+ * shared {@code Base} reachable only through two missing intermediates), so the caller must ship the
+ * class UNHARDENED -- with its original, javac-computed frames -- rather than a possibly-invalid one.
+ */
+ public boolean isHierarchyIncomplete() {
+ return hierarchyIncomplete;
+ }
+
+ @Override
+ protected String getCommonSuperClass(String type1, String type2) {
+ if (type1.equals(type2)) {
+ return type1;
+ }
+ if (hierarchy == null) {
+ return commonSuperFromBytes(type1, type2);
+ }
+ try {
+ Class> c1 = Class.forName(type1.replace('/', '.'), false, hierarchy);
+ Class> c2 = Class.forName(type2.replace('/', '.'), false, hierarchy);
+ if (c1.isAssignableFrom(c2)) {
+ return type1;
+ }
+ if (c2.isAssignableFrom(c1)) {
+ return type2;
+ }
+ if (c1.isInterface() || c2.isInterface()) {
+ return "java/lang/Object";
+ }
+ Class> c = c1;
+ do {
+ c = c.getSuperclass();
+ if (c == null) {
+ return "java/lang/Object";
+ }
+ } while (!c.isAssignableFrom(c2));
+ return c.getName().replace('.', '/');
+ } catch (Throwable t) {
+ // A type (or a supertype of it) can't be LOADED -- e.g. a shared Base supplied only by the
+ // target platform. Resolve from the class bytes so a real common supertype is still found
+ // rather than collapsing to Object, which would fail StackMapTable verification.
+ return commonSuperFromBytes(type1, type2);
+ }
+ }
+
+ /**
+ * Finds a common supertype from the class bytes, which does not require the (possibly absent)
+ * supertypes to be loadable. Mirrors the load-based logic exactly, using byte-based assignability
+ * that walks BOTH {@code super_class} and {@code interfaces[]}: if one type is a supertype (a
+ * superclass OR an implemented/extended interface) of the other it is returned; a merge involving an
+ * unrelated interface is {@code java/lang/Object} (the verifier treats an interface as Object); two
+ * unrelated classes resolve to the first shared {@code super_class}. Falls back to
+ * {@code java/lang/Object} only when the bytes cannot be read.
+ */
+ private String commonSuperFromBytes(String type1, String type2) {
+ // These are always non-array reference types (internal names like "pkg/A"), never array
+ // descriptors like "[Lpkg/A;", so readerFor()'s ".class" lookup is correct. That is ASM's
+ // contract for getCommonSuperClass, verified empirically against ASM 9.8 (the shaded version):
+ // for a merge of same-dimension reference arrays A[] and B[], ASM strips the array dimension and
+ // hands us the ELEMENT names "pkg/A" and "pkg/B", then re-wraps OUR result back into "[Lpkg/Base;"
+ // itself -- so a shared but unloadable Base still resolves to Base[] via the scalar path below.
+ // A merge of array-vs-scalar, or of arrays of different dimension, is resolved to Object by ASM
+ // internally and getCommonSuperClass is never called at all. So an array-descriptor branch here
+ // would be unreachable dead code; the scalar resolution is the whole contract.
+ if (type1.equals(type2)) {
+ return type1;
+ }
+ if (isAssignableFromBytes(type1, type2)) {
+ return type1;
+ }
+ if (isAssignableFromBytes(type2, type1)) {
+ return type2;
+ }
+ if (isInterfaceFromBytes(type1) || isInterfaceFromBytes(type2)) {
+ // A merge that involves an interface is java/lang/Object, and -- unlike the class fall-through
+ // below -- this is SOUND even when the interface hierarchy is only partly readable, so it is
+ // deliberately NOT flagged incomplete. The bytecode verifier treats every class as assignable
+ // to every (loadable) interface (JVMS 4.10.1.2 isJavaAssignable: a class is assignable to To
+ // when To is an interface, deferring the real check to the runtime invokeinterface). So an
+ // Object here still verifies wherever the merged value is later used as some interface I --
+ // including a later invokeinterface I -- regardless of whether an intermediate interface such
+ // as a missing target-only J (with J extends I) was readable. Discarding the class here would
+ // only un-harden a class the target verifies fine.
+ return "java/lang/Object";
+ }
+ String c = superNameFromBytes(type1);
+ Set seen = new LinkedHashSet();
+ // Stop before Object: isAssignableFromBytes(Object, type2) is unconditionally true, so testing it
+ // here would return Object as a "found" common super and skip the incompleteness check below --
+ // masking the case where type2's chain is broken by a missing intermediate that actually extends a
+ // nearer base (type1=A extends Base, type2=B extends an absent Missing extends Base). Reaching
+ // Object in this walk means no CLOSER readable ancestor was found, which is exactly the fall-through.
+ while (c != null && !"java/lang/Object".equals(c) && seen.add(c)) {
+ if (isAssignableFromBytes(c, type2)) {
+ return c;
+ }
+ c = superNameFromBytes(c);
+ }
+ // Falling through to Object. That is only SOUND when both chains are fully readable up to Object
+ // and genuinely share no closer ancestor. If either chain is broken by a missing intermediate, the
+ // real common base (reachable only through the absent class) is invisible here, so Object is a
+ // guess that may be too weak -- flag it so the caller ships the class unhardened with its original
+ // frames instead of emitting a possibly-invalid recomputed one.
+ if (!chainReachesObject(type1) || !chainReachesObject(type2)) {
+ hierarchyIncomplete = true;
+ }
+ return "java/lang/Object";
+ }
+
+ /**
+ * True when {@code type}'s superclass chain can be walked entirely to {@code java/lang/Object} (or a
+ * readable root) through readable bytes. False when a link in the chain is absent from the supplied
+ * jars, which means the byte-based resolver cannot see a shared base that lies beyond that gap.
+ */
+ private boolean chainReachesObject(String type) {
+ String c = type;
+ Set seen = new HashSet();
+ while (c != null && seen.add(c)) {
+ if ("java/lang/Object".equals(c)) {
+ return true;
+ }
+ ClassReader cr = readerFor(c);
+ if (cr == null) {
+ return false;
+ }
+ c = cr.getSuperName();
+ }
+ return true;
+ }
+
+ /** True when {@code sub} is {@code sup}, or extends/implements it (transitively) per the class bytes. */
+ private boolean isAssignableFromBytes(String sup, String sub) {
+ if (sup.equals(sub) || "java/lang/Object".equals(sup)) {
+ return true;
+ }
+ Set visited = new HashSet();
+ Deque stack = new ArrayDeque();
+ stack.push(sub);
+ while (!stack.isEmpty()) {
+ String cur = stack.pop();
+ if (!visited.add(cur)) {
+ continue;
+ }
+ if (cur.equals(sup)) {
+ return true;
+ }
+ ClassReader cr = readerFor(cur);
+ if (cr == null) {
+ continue;
+ }
+ if (cr.getSuperName() != null) {
+ stack.push(cr.getSuperName());
+ }
+ String[] interfaces = cr.getInterfaces();
+ if (interfaces != null) {
+ for (int i = 0; i < interfaces.length; i++) {
+ stack.push(interfaces[i]);
+ }
+ }
+ }
+ return false;
+ }
+
+ private boolean isInterfaceFromBytes(String type) {
+ ClassReader cr = readerFor(type);
+ return cr != null && (cr.getAccess() & Opcodes.ACC_INTERFACE) != 0;
+ }
+
+ /** The {@code super_class} name from {@code type}'s bytes, or null when Object or unreadable/absent. */
+ private String superNameFromBytes(String type) {
+ if (type == null || "java/lang/Object".equals(type)) {
+ return null;
+ }
+ ClassReader cr = readerFor(type);
+ return cr == null ? null : cr.getSuperName();
+ }
+
+ /** A {@link ClassReader} over {@code type}'s bytes from the hierarchy loader, or null if unreadable. */
+ private ClassReader readerFor(String type) {
+ if (type == null || hierarchy == null) {
+ return null;
+ }
+ InputStream in = hierarchy.getResourceAsStream(type + ".class");
+ if (in == null) {
+ return null;
+ }
+ try {
+ return new ClassReader(in);
+ } catch (Throwable t) {
+ return null;
+ } finally {
+ try {
+ in.close();
+ } catch (Throwable ignore) {
+ // best effort
+ }
+ }
+ }
+}
diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningConfig.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningConfig.java
new file mode 100644
index 00000000000..c44c8f0d50d
--- /dev/null
+++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningConfig.java
@@ -0,0 +1,244 @@
+/*
+ * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved.
+ * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
+ * This code is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License version 2 only, as
+ * published by the Free Software Foundation. Codename One designates this
+ * particular file as subject to the "Classpath" exception as provided
+ * by Oracle in the LICENSE file that accompanied this code.
+ *
+ * This code is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+ * version 2 for more details (a copy is included in the LICENSE file that
+ * accompanied this code).
+ *
+ * You should have received a copy of the GNU General Public License version
+ * 2 along with this work; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
+ *
+ * Please contact Codename One through http://www.codenameone.com/ if you
+ * need additional information or have any questions.
+ */
+package com.codename1.hardening;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * The resolved hardening settings for one build, derived from the {@code harden.*}
+ * build hints. A level sets the defaults; individual switches override them; and a
+ * per-platform switch ({@code harden..enabled}) can turn the whole thing
+ * off for one target. Nothing here references a builder's {@code BuildRequest}: the
+ * caller hands over a plain map of already-resolved hint values so the same config
+ * is usable from both the maven plugin and the cloud daemon.
+ */
+public final class HardeningConfig {
+ private final HardeningProfile profile;
+ private final boolean renameRequested;
+ private final boolean renameEnabled;
+ private final boolean encryptConstantStrings;
+ private final boolean encryptAllStrings;
+ private final boolean controlFlow;
+ private final boolean platformEnabled;
+ private final String platform;
+ private final String seed;
+ private final List extraKeepRules;
+
+ private HardeningConfig(HardeningProfile profile, boolean renameRequested, boolean renameEnabled,
+ boolean encryptConstantStrings, boolean encryptAllStrings,
+ boolean controlFlow, boolean platformEnabled, String platform,
+ String seed, List extraKeepRules) {
+ this.profile = profile;
+ this.renameRequested = renameRequested;
+ this.renameEnabled = renameEnabled;
+ this.encryptConstantStrings = encryptConstantStrings;
+ this.encryptAllStrings = encryptAllStrings;
+ this.controlFlow = controlFlow;
+ this.platformEnabled = platformEnabled;
+ this.platform = platform;
+ this.seed = seed;
+ this.extraKeepRules = extraKeepRules;
+ }
+
+ /**
+ * Builds a config from resolved hint values.
+ *
+ * @param hints the {@code harden.*} keys (prefix included), already resolved to their
+ * string values, e.g. {@code harden.level -> "aggressive"}
+ * @param platform one of {@code and|ios|mac|linux|win|javascript|javase|watch|tv}
+ * @param renameSupported false for Android, where R8 remains the sole renamer
+ */
+ public static HardeningConfig from(Map hints, String platform, boolean renameSupported) {
+ HardeningProfile level = HardeningProfile.parse(get(hints, "harden.level", "off"));
+ if (level == null) {
+ level = HardeningProfile.OFF;
+ }
+ boolean platformEnabled = boolTri(get(hints, "harden." + platform + ".enabled", "true"), true);
+
+ // renameRequested is the developer's intent; renameEnabled is whether the *engine* renames.
+ // On Android renameEnabled is false but the rename is still requested and delivered by R8.
+ boolean renameRequested = boolTri(get(hints, "harden.rename", null), level.renamesByDefault());
+ boolean renameEnabled = renameSupported && renameRequested;
+
+ String strings = get(hints, "harden.strings", null);
+ boolean encConst;
+ boolean encAll;
+ if (strings == null) {
+ encConst = level.encryptsConstantStringsByDefault();
+ encAll = level.encryptsAllStringsByDefault();
+ } else {
+ String v = strings.trim().toLowerCase();
+ if ("off".equals(v)) {
+ encConst = false;
+ encAll = false;
+ } else if ("constants".equals(v)) {
+ encConst = true;
+ encAll = false;
+ } else if ("all".equals(v)) {
+ encConst = true;
+ encAll = true;
+ } else {
+ // Unknown value: fall back to the level default rather than silently enabling the
+ // most invasive mode. The CLI (Main) rejects an unknown harden.strings up front.
+ encConst = level.encryptsConstantStringsByDefault();
+ encAll = level.encryptsAllStringsByDefault();
+ }
+ }
+
+ boolean cf = boolTri(get(hints, "harden.controlFlow", null), level.controlFlowByDefault());
+
+ String seed = get(hints, "harden.seed", null);
+
+ List keep = new ArrayList();
+ String keepRaw = get(hints, "harden.keep", null);
+ if (keepRaw != null) {
+ // Split only on newlines: a semicolon is legal ProGuard syntax inside a rule body
+ // (e.g. "-keep class com.example.Foo { *; }"), so splitting on ';' would shred rules.
+ for (String rule : keepRaw.split("\\r?\\n")) {
+ String t = rule.trim();
+ if (!t.isEmpty()) {
+ keep.add(t);
+ }
+ }
+ }
+
+ return new HardeningConfig(level, renameRequested, renameEnabled, encConst, encAll, cf,
+ platformEnabled, platform, seed, keep);
+ }
+
+ private static String get(Map hints, String key, String def) {
+ if (hints == null) {
+ return def;
+ }
+ String v = hints.get(key);
+ return v == null ? def : v;
+ }
+
+ private static boolean boolTri(String v, boolean def) {
+ if (v == null) {
+ return def;
+ }
+ String t = v.trim().toLowerCase();
+ if (t.isEmpty()) {
+ return def;
+ }
+ if ("true".equals(t) || "1".equals(t) || "2".equals(t) || "3".equals(t) || "on".equals(t)) {
+ return true;
+ }
+ if ("false".equals(t) || "0".equals(t) || "off".equals(t)) {
+ return false;
+ }
+ return def;
+ }
+
+ /** True when any transform should run: the level is on and this platform is not opted out. */
+ public boolean isActive() {
+ return profile != HardeningProfile.OFF && platformEnabled;
+ }
+
+ public HardeningProfile getProfile() {
+ return profile;
+ }
+
+ public boolean isRenameEnabled() {
+ return renameEnabled;
+ }
+
+ /**
+ * Whether renaming was requested, independent of whether this engine performs it. On Android
+ * this is true while {@link #isRenameEnabled()} is false, because R8 does the rename externally.
+ */
+ public boolean isRenameRequested() {
+ return renameRequested;
+ }
+
+ public boolean isEncryptConstantStrings() {
+ return encryptConstantStrings;
+ }
+
+ public boolean isEncryptAllStrings() {
+ return encryptAllStrings;
+ }
+
+ public boolean isAnyStringEncryption() {
+ return encryptConstantStrings || encryptAllStrings;
+ }
+
+ public boolean isControlFlow() {
+ return controlFlow;
+ }
+
+ /**
+ * How many opaque-predicate guards control-flow obfuscation inserts per method: {@code paranoid}
+ * inserts two (nested) where {@code aggressive} inserts one, which is what makes {@code paranoid}
+ * a genuinely stronger tier rather than an alias.
+ */
+ public int getControlFlowIntensity() {
+ if (!controlFlow) {
+ return 0;
+ }
+ return profile == HardeningProfile.PARANOID ? 2 : 1;
+ }
+
+ public boolean isPlatformEnabled() {
+ return platformEnabled;
+ }
+
+ public String getPlatform() {
+ return platform;
+ }
+
+ public String getSeed() {
+ return seed;
+ }
+
+ public List getExtraKeepRules() {
+ return extraKeepRules;
+ }
+
+ /** The transforms actually enabled, for the report and the mapping header. */
+ public List enabledTransforms() {
+ List t = new ArrayList();
+ if (renameEnabled) {
+ t.add("rename");
+ }
+ if (encryptConstantStrings || encryptAllStrings) {
+ t.add(encryptAllStrings ? "strings:all" : "strings:constants");
+ }
+ if (controlFlow) {
+ t.add("controlFlow");
+ }
+ return t;
+ }
+
+ @Override
+ public String toString() {
+ return "HardeningConfig" + Arrays.asList(
+ "profile=" + profile, "platform=" + platform, "platformEnabled=" + platformEnabled,
+ "rename=" + renameEnabled, "encConst=" + encryptConstantStrings,
+ "encAll=" + encryptAllStrings, "controlFlow=" + controlFlow);
+ }
+}
diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java
new file mode 100644
index 00000000000..d767f5fbf6c
--- /dev/null
+++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningEngine.java
@@ -0,0 +1,901 @@
+/*
+ * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved.
+ * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
+ * This code is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License version 2 only, as
+ * published by the Free Software Foundation. Codename One designates this
+ * particular file as subject to the "Classpath" exception as provided
+ * by Oracle in the LICENSE file that accompanied this code.
+ *
+ * This code is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+ * version 2 for more details (a copy is included in the LICENSE file that
+ * accompanied this code).
+ *
+ * You should have received a copy of the GNU General Public License version
+ * 2 along with this work; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
+ *
+ * Please contact Codename One through http://www.codenameone.com/ if you
+ * need additional information or have any questions.
+ */
+package com.codename1.hardening;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.OutputStreamWriter;
+import java.io.Writer;
+import java.nio.charset.Charset;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * The one public entry point of the hardening engine. Given the merged application
+ * jar and a resolved config, it produces a hardened jar plus a cross-platform
+ * ProGuard mapping.
+ *
+ * The pipeline is: demux the fat jar to a class-only jar (non-class entries
+ * preserved byte-for-byte); assemble keep rules; rename with ProGuard using the
+ * prefixed dictionary (skipped on Android, where R8 remains the sole renamer);
+ * encrypt strings; guard against ParparVM mangle collisions; verify every class;
+ * rebuild the output jar; and finalize the mapping.
+ */
+public final class HardeningEngine {
+
+ public static final String ENGINE_VERSION = "1.0.0";
+ public static final String PROGUARD_VERSION = "7.3.2";
+ /** Highest Java feature version whose class files ProGuard 7.3.2 can read. */
+ public static final int PROGUARD_MAX_JDK = 20;
+
+ private HardeningEngine() {
+ }
+
+ /**
+ * Whether ProGuard can run on the current JVM. 7.3.2 cannot read class files newer than
+ * JDK 20 (it fails on the JDK's own module classes), so renaming must run on JDK 8-20 --
+ * the cloud daemon forks the engine on JDK 17. String encryption and control flow have no
+ * such limit.
+ */
+ public static boolean proguardCanRunHere() {
+ return currentJdkFeature() <= PROGUARD_MAX_JDK;
+ }
+
+ static int currentJdkFeature() {
+ String v = System.getProperty("java.specification.version", "1.8");
+ if (v.startsWith("1.")) {
+ v = v.substring(2);
+ }
+ int dot = v.indexOf('.');
+ if (dot >= 0) {
+ v = v.substring(0, dot);
+ }
+ try {
+ return Integer.parseInt(v.trim());
+ } catch (NumberFormatException e) {
+ return 8;
+ }
+ }
+
+ public static String engineVersion() {
+ return ENGINE_VERSION;
+ }
+
+ public static HardeningResult harden(HardeningRequest req) throws HardeningException {
+ HardeningConfig cfg = req.getConfig();
+ require(req.getInputJar() != null && req.getInputJar().isFile(), "input jar is missing");
+ require(req.getOutputJar() != null, "output jar path is missing");
+ require(cfg != null, "config is missing");
+
+ if (cfg.getProfile() == HardeningProfile.OFF) {
+ return HardeningResult.skipped(HardeningResult.Outcome.SKIPPED_NOT_REQUESTED, req.getInputJar());
+ }
+ if (!cfg.isPlatformEnabled()) {
+ return HardeningResult.skipped(HardeningResult.Outcome.SKIPPED_PLATFORM_DISABLED, req.getInputJar());
+ }
+ // A non-off level whose transforms are all individually disabled (e.g. harden.rename=false +
+ // harden.strings=off + no control flow) does nothing -- don't rebuild the jar and stamp it
+ // "hardened" with an empty transform set. Treat it as not requested.
+ if (!willApplyAnyTransform(cfg)) {
+ return HardeningResult.skipped(HardeningResult.Outcome.SKIPPED_NOT_REQUESTED, req.getInputJar());
+ }
+
+ File workDir = req.getWorkDir();
+ if (workDir == null) {
+ workDir = req.getOutputJar().getAbsoluteFile().getParentFile();
+ }
+ workDir.mkdirs();
+
+ try {
+ return run(req, cfg, workDir);
+ } catch (IOException e) {
+ throw new HardeningException("Hardening failed: " + e.getMessage(), e);
+ }
+ }
+
+ private static HardeningResult run(HardeningRequest req, HardeningConfig cfg, File workDir)
+ throws HardeningException, IOException {
+ File classesJar = new File(workDir, "classes-in.jar");
+ JarDemuxer.NonClassEntries nonClass = JarDemuxer.split(req.getInputJar(), classesJar);
+ Map inClasses = JarDemuxer.readClasses(classesJar);
+ int classesIn = inClasses.size();
+
+ List keepRules = new ArrayList();
+ keepRules.addAll(BuiltinKeepRules.rules(req.getMainClass()));
+ InputJarKeepScanner scanner = new InputJarKeepScanner();
+ scanner.scan(inClasses);
+ keepRules.addAll(scanner.keepRules());
+ // Keep classes named by META-INF/services descriptors: those files are copied verbatim, so
+ // ServiceLoader would fail if the service interface or a provider class were renamed.
+ keepRules.addAll(serviceDescriptorKeeps(nonClass));
+ keepRules.addAll(cfg.getExtraKeepRules());
+
+ // Export the derived keep rules for a downstream renamer the engine doesn't drive itself.
+ // On Android R8 is the sole renamer (isRenameEnabled()==false), so without this the classes
+ // the scanner found reflectively (Class.forName targets, service providers, name-bound
+ // property objects, the app's own harden.keep) would be invisible to R8 and get renamed.
+ if (req.getR8KeepFile() != null) {
+ StringBuilder r8 = new StringBuilder();
+ for (String rule : keepRules) {
+ r8.append(rule).append('\n');
+ }
+ writeText(req.getR8KeepFile(), r8.toString());
+ }
+
+ Map renamed;
+ int renamedCount = 0;
+ File mappingFile = req.getMappingFile();
+ File hierarchyJar;
+
+ if (cfg.isRenameEnabled()) {
+ if (!proguardCanRunHere()) {
+ throw new HardeningException("App hardening's renamer (ProGuard " + PROGUARD_VERSION
+ + ") must run on JDK 8-" + PROGUARD_MAX_JDK + ", but this JVM is JDK "
+ + currentJdkFeature() + ". The Codename One build server runs the engine on "
+ + "JDK 17; for a local hardened build, run it on JDK 8-" + PROGUARD_MAX_JDK + ".");
+ }
+ File dict = new File(workDir, "cn1-dict.txt");
+ // Seed the dictionary so harden.seed / the build key actually changes the mapping.
+ Cn1NameFactory.writeDictionary(dict,
+ Cn1NameFactory.dictionarySizeFor(classesIn, maxMemberNamingScope(inClasses)),
+ deriveSeed(cfg, req.getBuildKey()));
+ File renamedJar = new File(workDir, "renamed.jar");
+ ProGuardRunner.rename(classesJar, renamedJar, mappingFile,
+ req.getLibraryJars(), keepRules, dict, workDir, cfg.getPlatform());
+ renamed = JarDemuxer.readClasses(renamedJar);
+ renamedCount = countRenamed(inClasses.keySet(), renamed.keySet());
+ hierarchyJar = renamedJar;
+ } else {
+ renamed = new LinkedHashMap(inClasses);
+ hierarchyJar = classesJar;
+ if (mappingFile != null) {
+ writeText(mappingFile, "");
+ }
+ }
+
+ // Classloader over the (renamed) app classes plus the library jars, so stack-map frame
+ // computation resolves the class hierarchy without loading types through the engine's own
+ // classloader (see FrameClassWriter).
+ ClassLoader hierarchy = buildHierarchyLoader(hierarchyJar, req.getLibraryJars());
+
+ int seed = deriveSeed(cfg, req.getBuildKey());
+ int encryptedStrings = 0;
+ int concatLiterals = 0;
+ int legacyInterfaceConstants = 0;
+ int oversizedLiterals = 0;
+ int condyLiterals = 0;
+ int indyLiterals = 0;
+ int shortLiterals = 0;
+ int hierarchyIncompleteSkips = 0;
+ int externallyReadConstants = 0;
+ int clinitFullLiterals = 0;
+ int annotationLiterals = 0;
+ int jarExcludedLiterals = 0;
+ int libraryExcludedLiterals = 0;
+ int sourcePreservedConstants = 0;
+ boolean stringsApplied = cfg.isAnyStringEncryption() && stringEncryptionSafeFor(cfg.getPlatform());
+ if (stringsApplied) {
+ // In "constants" mode, first collect the values declared as static-final String
+ // constants across the whole jar, so we encrypt exactly those (and javac's inlined
+ // copies) and nothing incidental.
+ java.util.Set constantValues = null;
+ if (!cfg.isEncryptAllStrings()) {
+ constantValues = new java.util.HashSet();
+ for (byte[] cls : renamed.values()) {
+ StringEncryptTransform.collectConstantValues(cls, constantValues);
+ }
+ }
+ // On a ParparVM-C target a compile-time literal is a never-interned constant-pool object,
+ // while an encrypted app copy is intern()ed, so encrypting an app value that ALSO appears as
+ // a literal in an UNHARDENED library class (core, java-runtime, dependencies -- never in
+ // 'renamed') would break a valid literal == against the library copy that held before
+ // hardening. Collect those library literals and exclude them from encryption. On a real-JVM
+ // or Android target every compile-time literal is interned to the same pool intern() uses, so
+ // the constraint does not apply and the scan is skipped (full coverage).
+ java.util.Set libraryLiterals = null;
+ if (translatesThroughParparVMC(cfg.getPlatform()) && req.getLibraryJars() != null) {
+ libraryLiterals = new java.util.HashSet();
+ for (File lib : req.getLibraryJars()) {
+ collectJarLiterals(lib, libraryLiterals);
+ }
+ }
+ final java.util.Set libLiterals =
+ libraryLiterals != null && !libraryLiterals.isEmpty() ? libraryLiterals : null;
+ java.util.Set libraryExcluded = new java.util.HashSet();
+ // On a target that compiles the .java/.kt source bundled in the app jar against the
+ // transformed classes (Android places those sources in src/main/java and the hardened classes
+ // in libs/userClasses.jar), stripping a static-final String ConstantValue would break a
+ // case-label/annotation/const-initializer reference to it in that source. Collect the
+ // identifiers such sources reference so those constants keep their ConstantValue.
+ java.util.Set srcNames = null;
+ if (compilesCarriedSource(cfg.getPlatform())) {
+ java.util.Set ids = new java.util.HashSet();
+ for (Map.Entry e : nonClass.asMap().entrySet()) {
+ String name = e.getKey().toLowerCase();
+ if (name.endsWith(".java") || name.endsWith(".kt")) {
+ collectSourceIdentifiers(new String(e.getValue(),
+ java.nio.charset.Charset.forName("UTF-8")), ids);
+ }
+ }
+ if (!ids.isEmpty()) {
+ srcNames = ids;
+ }
+ }
+ final java.util.Set sourceReferencedNames = srcNames;
+ // Fields read by a GETSTATIC anywhere in the jar (a non-inlined constant read from generated
+ // bytecode): their ConstantValue must not be moved to , which would change reentrant
+ // initialization ordering. Scanned once over the renamed classes so owner/name match the
+ // renamed field names the transform checks.
+ final java.util.Set externallyReadStaticFields = new java.util.HashSet();
+ for (byte[] cls : renamed.values()) {
+ StringEncryptTransform.collectGetStaticStringReads(cls, externallyReadStaticFields, hierarchy);
+ }
+ // Pass 1 (from a snapshot of the input bytes): transform every class, tally the counts, and
+ // collect the values any class could NOT encrypt (a method too full for the decode call, or a
+ // class whose pool cannot fit the decoder). A value encrypted+interned in one class but left
+ // plaintext in another would fail a valid literal == on ParparVM's deduplicated pool, so
+ // those values must be excluded jar-wide -- encrypted everywhere or nowhere.
+ java.util.Map original = new java.util.HashMap(renamed);
+ java.util.Set jarExcluded = new java.util.HashSet();
+ for (Map.Entry e : renamed.entrySet()) {
+ StringEncryptTransform t = new StringEncryptTransform(
+ cfg.isEncryptAllStrings(), seed, hierarchy, constantValues, null);
+ t.setLibraryLiterals(libLiterals);
+ t.setSourceReferencedNames(sourceReferencedNames);
+ t.setExternallyReadStaticFields(externallyReadStaticFields);
+ e.setValue(t.transform(e.getValue()));
+ jarExcluded.addAll(t.getNewlyExcluded());
+ libraryExcluded.addAll(t.getLibraryExcludedValues());
+ sourcePreservedConstants += t.getSourcePreservedConstantCount();
+ externallyReadConstants += t.getExternallyReadConstantCount();
+ encryptedStrings += t.getEncryptedCount();
+ concatLiterals += t.getConcatLiteralCount();
+ legacyInterfaceConstants += t.getLegacyInterfaceConstantCount();
+ oversizedLiterals += t.getOversizedLiteralCount();
+ condyLiterals += t.getCondyLiteralCount();
+ indyLiterals += t.getIndyLiteralCount();
+ shortLiterals += t.getShortLiteralCount();
+ hierarchyIncompleteSkips += t.isHierarchyIncompleteSkipped() ? 1 : 0;
+ clinitFullLiterals += t.getClinitFullLiteralCount();
+ annotationLiterals += t.getAnnotationLiteralCount();
+ }
+ // Pass 2 only when pass 1 found values it could not consistently encrypt: re-transform every
+ // class from the original bytes with those values excluded (so a value is plaintext in all
+ // classes or none), replacing pass 1's outputs and re-tallying. In the common case the
+ // exclusion set is empty and pass 1's result stands -- one transform per class.
+ if (!jarExcluded.isEmpty()) {
+ encryptedStrings = 0;
+ concatLiterals = 0;
+ legacyInterfaceConstants = 0;
+ oversizedLiterals = 0;
+ condyLiterals = 0;
+ indyLiterals = 0;
+ shortLiterals = 0;
+ hierarchyIncompleteSkips = 0;
+ externallyReadConstants = 0;
+ clinitFullLiterals = 0;
+ annotationLiterals = 0;
+ sourcePreservedConstants = 0;
+ libraryExcluded.clear();
+ for (Map.Entry e : renamed.entrySet()) {
+ StringEncryptTransform t = new StringEncryptTransform(
+ cfg.isEncryptAllStrings(), seed, hierarchy, constantValues, jarExcluded);
+ t.setLibraryLiterals(libLiterals);
+ t.setSourceReferencedNames(sourceReferencedNames);
+ t.setExternallyReadStaticFields(externallyReadStaticFields);
+ e.setValue(t.transform(original.get(e.getKey())));
+ libraryExcluded.addAll(t.getLibraryExcludedValues());
+ sourcePreservedConstants += t.getSourcePreservedConstantCount();
+ externallyReadConstants += t.getExternallyReadConstantCount();
+ encryptedStrings += t.getEncryptedCount();
+ concatLiterals += t.getConcatLiteralCount();
+ legacyInterfaceConstants += t.getLegacyInterfaceConstantCount();
+ oversizedLiterals += t.getOversizedLiteralCount();
+ condyLiterals += t.getCondyLiteralCount();
+ indyLiterals += t.getIndyLiteralCount();
+ shortLiterals += t.getShortLiteralCount();
+ hierarchyIncompleteSkips += t.isHierarchyIncompleteSkipped() ? 1 : 0;
+ clinitFullLiterals += t.getClinitFullLiteralCount();
+ annotationLiterals += t.getAnnotationLiteralCount();
+ }
+ }
+ jarExcludedLiterals = jarExcluded.size();
+ libraryExcludedLiterals = libraryExcluded.size();
+ }
+
+ int guardedMethods = 0;
+ int oversizedGuardMethods = 0;
+ boolean controlFlowApplied = cfg.isControlFlow() && controlFlowSafeFor(cfg.getPlatform());
+ if (controlFlowApplied) {
+ for (Map.Entry e : renamed.entrySet()) {
+ ControlFlowTransform t = new ControlFlowTransform(hierarchy, cfg.getControlFlowIntensity());
+ byte[] out = t.transform(e.getValue());
+ if (out != e.getValue()) {
+ e.setValue(out);
+ }
+ oversizedGuardMethods += t.getOversizedMethods();
+ guardedMethods += t.getGuardedMethods();
+ }
+ }
+
+ // Even when the config asked for a transform, the input may contain nothing eligible (e.g.
+ // rename off, and no static-final string longer than two characters to encrypt): every
+ // counter stays zero and no transform actually ran. Stamping cn1.hardened=true for a
+ // byte-unchanged app would be dishonest, so report SKIPPED and let the caller keep the input.
+ // Android still counts as hardened here because R8 renames downstream (isRenameRequested).
+ boolean anyApplied = cfg.isRenameEnabled()
+ || cfg.isRenameRequested()
+ || (stringsApplied && encryptedStrings > 0)
+ || (controlFlowApplied && guardedMethods > 0);
+ if (!anyApplied) {
+ return HardeningResult.skipped(HardeningResult.Outcome.SKIPPED_NOT_REQUESTED, req.getInputJar());
+ }
+
+ // The a.b_c / a.b.c -> a_b_c collision only exists in the ParparVM C symbol mangle. On
+ // Android (R8/DEX -- and the engine does not even rename there) and JavaSE (plain JVM),
+ // '.' vs '_' stay distinct, so two legal classes must not abort the build.
+ if (translatesThroughParparVMC(cfg.getPlatform())) {
+ MangleCollisionCheck.check(renamed.keySet());
+ }
+ OutputVerifier.verify(renamed, hierarchy);
+
+ // Idempotence marker: a nested builder delegation must not harden twice.
+ nonClass.asMap().put("META-INF/CN1-HARDENED",
+ (ENGINE_VERSION + " " + cfg.getProfile().name().toLowerCase())
+ .getBytes(java.nio.charset.Charset.forName("UTF-8")));
+
+ JarDemuxer.rebuild(req.getOutputJar(), renamed, nonClass);
+
+ // Only the engine's own rename produces a mapping worth an id. On Android the engine does
+ // not rename (R8 is the sole renamer and produces the real per-build mapping later), so the
+ // engine mapping is empty -- hashing it would stamp a meaningless constant id. Leave it empty.
+ String mappingId = "";
+ if (mappingFile != null && cfg.isRenameEnabled()) {
+ // Record the real source filename for classes whose SourceFile the engine just stripped and
+ // whose name cannot reconstruct it (Kotlin Screen.kt, a package-private class in Main.java),
+ // captured from the INPUT classes before the strip. Done before finalize so the id covers it.
+ MappingWriter.injectSourceFiles(mappingFile, collectSourceFiles(inClasses));
+ mappingId = MappingWriter.finalizeMapping(mappingFile, ENGINE_VERSION, PROGUARD_VERSION,
+ cfg.getPlatform(), req.getBuildKey());
+ }
+
+ HardeningResult result = HardeningResult.hardened(req.getOutputJar(), mappingFile);
+ result.setClassesIn(classesIn);
+ result.setClassesOut(renamed.size());
+ result.setRenamedClasses(renamedCount);
+ result.setEncryptedStrings(encryptedStrings);
+ result.setMappingId(mappingId);
+ // Report only what actually ran, so a "half-hardened" build can never claim a
+ // transform it skipped. This is what the downstream verifier checks against.
+ if (cfg.isRenameEnabled()) {
+ result.getTransformsApplied().add("rename");
+ } else if (cfg.isRenameRequested()) {
+ // Android: the engine doesn't rename, R8 does. Still a rename, still hardened.
+ result.getTransformsApplied().add("rename:r8");
+ }
+ if (stringsApplied && encryptedStrings > 0) {
+ result.getTransformsApplied().add(cfg.isEncryptAllStrings() ? "strings:all" : "strings:constants");
+ }
+ if (controlFlowApplied && guardedMethods > 0) {
+ result.getTransformsApplied().add(cfg.getControlFlowIntensity() >= 2
+ ? "controlFlow:intense" : "controlFlow");
+ }
+ if (cfg.isControlFlow() && !controlFlowApplied) {
+ result.getWarnings().add("control-flow obfuscation is not applied on platform '"
+ + cfg.getPlatform() + "' (unsafe for the ParparVM optimizer); skipped");
+ }
+ if (cfg.isAnyStringEncryption() && !stringsApplied) {
+ result.getWarnings().add("string encryption is not applied on platform '"
+ + cfg.getPlatform() + "' (would break the JavaScript native bridge); skipped");
+ }
+ if (stringsApplied && concatLiterals > 0) {
+ // javac from JDK 9 compiles string concatenation to an invokedynamic whose literal
+ // fragments live in the StringConcatFactory recipe, not in an LDC or a ConstantValue, so
+ // the string channels cannot reach them. Report it rather than let a build believe it is
+ // fully string-encrypted; -XDstringConcat=inline (or an older -target) emits StringBuilder
+ // the engine does encrypt.
+ result.getWarnings().add(concatLiterals + " string-concatenation literal group(s) compiled "
+ + "to invokedynamic (JDK 9+ javac) were not encrypted; compile with "
+ + "-XDstringConcat=inline or an older -target to encrypt concatenation literals");
+ }
+ if (stringsApplied && legacyInterfaceConstants > 0) {
+ // A pre-Java-8 interface cannot host a /decoder, so its own static-final String
+ // constants stay plaintext in that class file. Reads elsewhere were inlined and are
+ // encrypted; report the declaring-interface leak rather than ship it unremarked.
+ result.getWarnings().add(legacyInterfaceConstants + " static-final String constant(s) on "
+ + "pre-Java-8 interface(s) were not encrypted (such interfaces cannot host the "
+ + "decoder); recompile the interface at -target 8+ to encrypt its constant pool");
+ }
+ if (stringsApplied && condyLiterals > 0) {
+ // Java 11+ can carry plaintext through an LDC constant-dynamic whose bootstrap arguments
+ // hold the string; it is resolved at link time, so it cannot be rewritten to a decode call.
+ result.getWarnings().add(condyLiterals + " constant-dynamic (LDC ConstantDynamic) site(s) "
+ + "carrying string bootstrap arguments were not encrypted; such constants are "
+ + "resolved at link time and remain in plaintext");
+ }
+ if (stringsApplied && indyLiterals > 0) {
+ // A custom invokedynamic (not a StringConcatFactory concat) can carry a plaintext string in
+ // its bootstrap arguments, resolved at link time, which no LDC/ConstantValue pass reaches.
+ result.getWarnings().add(indyLiterals + " invokedynamic site(s) carrying string bootstrap "
+ + "arguments (a non-concat bootstrap emitted by a bytecode generator) were not "
+ + "encrypted; such constants are resolved at link time and remain in plaintext");
+ }
+ if (stringsApplied && cfg.isEncryptAllStrings() && shortLiterals > 0) {
+ // One- and two-character literals are left plaintext (the decoder overhead dwarfs them, and a
+ // two-char value is trivially brute-forced even encrypted). Disclose so strings:all is honest.
+ result.getWarnings().add(shortLiterals + " distinct one- or two-character string literal(s) "
+ + "were left in plaintext (too short to be worth encrypting); a short value is "
+ + "trivially recovered even when encrypted, so this is a disclosure note");
+ }
+ if (hierarchyIncompleteSkips > 0) {
+ // A class whose frame merge could not be resolved past a supertype absent from the supplied
+ // jars is shipped UNHARDENED (original frames) rather than risk an Object-widened frame that
+ // fails on-device verification. Disclose it so the coverage claim is honest.
+ result.getWarnings().add(hierarchyIncompleteSkips + " class(es) were left unhardened because a "
+ + "supertype needed to compute their stack-map frames was absent from the supplied "
+ + "library jars; supplying that platform's jars lets them be hardened");
+ }
+ if (stringsApplied && externallyReadConstants > 0) {
+ // A constant read by a GETSTATIC (non-inlined, from generated bytecode) keeps its ConstantValue
+ // -- moving it into would change reentrant-initialization ordering -- so it stays
+ // plaintext. Disclose it rather than let strings:all imply it was encrypted.
+ result.getWarnings().add(externallyReadConstants + " static-final String constant(s) are read by "
+ + "a non-inlined GETSTATIC and were left in plaintext to preserve class-initialization "
+ + "ordering; move such a secret out of a constant to hide it");
+ }
+ if (stringsApplied && oversizedLiterals > 0) {
+ // A literal longer than ~21,845 chars can widen to a 3-byte-per-char constant whose
+ // ciphertext overflows the 65535-byte constant pool, so it is left plaintext. Report it
+ // rather than let an strings:all build claim it encrypted everything.
+ result.getWarnings().add(oversizedLiterals + " string literal(s) were too large to encrypt "
+ + "(their ciphertext would overflow the 65535-byte constant pool) and remain in "
+ + "plaintext; move a large embedded secret/blob out of a string constant to hide it");
+ }
+ if (stringsApplied && clinitFullLiterals > 0) {
+ result.getWarnings().add(clinitFullLiterals + " static-final String constant(s) were left in "
+ + "plaintext because the class is near a JVM limit (its static initializer's size or "
+ + "the constant pool) and could not hold the decode step; those field values are "
+ + "dead once javac inlines their reads, so this is a disclosure note");
+ }
+ if (stringsApplied && jarExcludedLiterals > 0) {
+ // Values that at least one class could not encrypt (a method already near the 65535-byte
+ // limit, or a class whose constant pool cannot fit the decoder) are left plaintext in EVERY
+ // class, so a decoded+interned copy never compares != to a plaintext copy on ParparVM.
+ result.getWarnings().add(jarExcludedLiterals + " distinct string value(s) were left in "
+ + "plaintext in every class because at least one class could not encrypt them (a "
+ + "method or constant pool near the JVM limit); those literals stay readable");
+ }
+ if (stringsApplied && sourcePreservedConstants > 0) {
+ // A static-final String referenced by a bundled .java/.kt source in a constant-expression
+ // context keeps its ConstantValue so that source still compiles; disclose the plaintext.
+ result.getWarnings().add(sourcePreservedConstants + " static-final String constant(s) kept "
+ + "their plaintext ConstantValue because a bundled .java/.kt source may reference them "
+ + "as a compile-time constant (a case label or annotation value), which requires the "
+ + "attribute to compile; their inlined reads are still encrypted");
+ }
+ if (stringsApplied && libraryExcludedLiterals > 0) {
+ // Values that also appear as a literal in an unhardened library class are left plaintext so a
+ // literal == against the library's (never interned) constant-pool copy still holds on ParparVM.
+ result.getWarnings().add(libraryExcludedLiterals + " distinct string value(s) were left in "
+ + "plaintext because an unhardened framework/dependency class also holds them as a "
+ + "literal (encrypting only the app copy would break a valid reference-equality check "
+ + "against the library copy on the ParparVM native targets); those literals stay readable");
+ }
+ if (stringsApplied && annotationLiterals > 0) {
+ // Annotation element values live in the annotation metadata, not an LDC or a ConstantValue,
+ // so no encryption channel reaches them. CN1 has no reflection to read them back, so this is
+ // a disclosure note; don't put a secret in an annotation and expect it hidden.
+ result.getWarnings().add(annotationLiterals + " string(s) in annotation values/defaults were "
+ + "not encrypted (they live in annotation metadata, not code); do not place a secret "
+ + "in an annotation");
+ }
+ if (controlFlowApplied && oversizedGuardMethods > 0) {
+ result.getWarnings().add(oversizedGuardMethods + " method(s) were left with plain control "
+ + "flow because their class is already near a JVM limit (a method near the "
+ + "65535-byte limit, or a constant pool near the 65535-entry limit) and adding the "
+ + "guard would overflow it");
+ }
+ if (req.getReportFile() != null) {
+ writeReport(req.getReportFile(), cfg, result);
+ }
+ return result;
+ }
+
+ /**
+ * String encryption is disabled on the JavaScript port for now: the ParparVM JS backend's
+ * minifier treats certain string literals as live references into the CN1 native bridge, and
+ * encrypting one would break the bridge. Every other port is safe (the decoder is ordinary
+ * translated/compiled code).
+ */
+ /** True when at least one transform will actually run for this config and platform. */
+ static boolean willApplyAnyTransform(HardeningConfig cfg) {
+ // An off profile skips everything: harden() returns SKIPPED for OFF (before any transform),
+ // even when a stale individual override such as harden.rename=true is still present. Match
+ // that here so a non-entitled build that explicitly set harden.level=off is skipped rather
+ // than rejected as not-entitled just because an override was left behind.
+ if (cfg.getProfile() == HardeningProfile.OFF) {
+ return false;
+ }
+ // A per-platform opt-out means nothing runs for this target -- so a non-entitled build with
+ // harden..enabled=false is skipped, not rejected as not-entitled.
+ if (!cfg.isPlatformEnabled()) {
+ return false;
+ }
+ // renameRequested (not renameEnabled): on Android the engine does not rename, but R8 does,
+ // so a rename-only Android build is still a hardened build and must not be skipped.
+ if (cfg.isRenameRequested()) {
+ return true;
+ }
+ if (cfg.isAnyStringEncryption() && stringEncryptionSafeFor(cfg.getPlatform())) {
+ return true;
+ }
+ return cfg.isControlFlow() && controlFlowSafeFor(cfg.getPlatform());
+ }
+
+ static boolean stringEncryptionSafeFor(String platform) {
+ return !"javascript".equals(platform);
+ }
+
+ /**
+ * Control-flow obfuscation runs only on the JVM-bytecode ports (Android, desktop). The
+ * ParparVM native ports (ios/mac/watch/tv/win/linux) translate to C, where the opaque
+ * predicate fights the optimizer/devirtualizer and the arithmetic reducer, and the JavaScript
+ * port inflates the bundle and confuses the suspension analysis; those are left untouched.
+ */
+ static boolean controlFlowSafeFor(String platform) {
+ return "and".equals(platform) || "android".equals(platform)
+ || "javase".equals(platform) || "desktop".equals(platform);
+ }
+
+ /**
+ * The ports whose classes are translated to C by ParparVM, where the class/package mangle
+ * ({@code . / $} all collapse to {@code _}) can make two legal Java names share one C symbol.
+ * The collision guard is meaningful only for these; Android (DEX) and JavaSE (JVM) keep the
+ * names distinct, and JavaScript uses a different mangling entirely.
+ */
+ static boolean translatesThroughParparVMC(String platform) {
+ return "ios".equals(platform) || "mac".equals(platform) || "watch".equals(platform)
+ || "tv".equals(platform) || "win".equals(platform) || "linux".equals(platform);
+ }
+
+ /**
+ * True for a target that javac/kotlinc-compiles the {@code .java}/{@code .kt} sources bundled in the
+ * app jar against the transformed classes. Android does (the sources land in {@code src/main/java}
+ * and the hardened classes in {@code libs/userClasses.jar}); iOS routes carried source into the
+ * resource tree and never compiles it, win/linux compile only the ParparVM translator's generated
+ * {@code .java}, and JavaSE runs the bytecode directly. On such a target a stripped
+ * {@code ConstantValue} would break a constant-expression reference from the carried source.
+ */
+ static boolean compilesCarriedSource(String platform) {
+ return "and".equals(platform) || "android".equals(platform);
+ }
+
+ /**
+ * Maps the original (dotted) class name to its {@code SourceFile} attribute, for the classes worth
+ * recording in the mapping: those whose filename differs from the synthesized {@code .java}
+ * default, i.e. Kotlin sources and package-private classes declared in a differently named file. Read
+ * from the INPUT classes, before the engine strips the attribute.
+ */
+ private static java.util.Map collectSourceFiles(java.util.Map classes) {
+ java.util.Map out = new java.util.HashMap();
+ for (java.util.Map.Entry e : classes.entrySet()) {
+ final String[] sf = new String[1];
+ new org.objectweb.asm.ClassReader(e.getValue()).accept(
+ new org.objectweb.asm.ClassVisitor(org.objectweb.asm.Opcodes.ASM9) {
+ @Override
+ public void visitSource(String source, String debug) {
+ sf[0] = source;
+ }
+ // NOT SKIP_DEBUG -- that flag skips the SourceFile attribute, which is exactly what
+ // visitSource reports and what we are here to capture.
+ }, org.objectweb.asm.ClassReader.SKIP_CODE | org.objectweb.asm.ClassReader.SKIP_FRAMES);
+ if (sf[0] != null && sf[0].length() > 0 && !sf[0].equals(defaultSourceFile(e.getKey()))) {
+ out.put(e.getKey().replace('/', '.'), sf[0]);
+ }
+ }
+ return out;
+ }
+
+ /**
+ * The largest member NAMING scope in {@code classes}, which the obfuscation dictionary must exceed so
+ * ProGuard never exhausts it and falls back to short names. Two scopes matter, both beyond the class
+ * count:
+ *
+ * - Fields: a class cannot declare two fields with the same name, so a class's fields all need
+ * distinct names -- the per-class field count.
+ * - Methods: ProGuard cannot give two same-descriptor methods in one inheritance hierarchy the
+ * same obfuscated name without creating an accidental override, so same-descriptor methods
+ * accumulate ACROSS a hierarchy, not just within one class. The exact per-hierarchy count needs
+ * a full hierarchy walk; the count of methods sharing a descriptor across the WHOLE jar is a
+ * safe (and, for a generated deep hierarchy, tight) upper bound.
+ *
+ */
+ static int maxMemberNamingScope(java.util.Map classes) {
+ final int[] maxFields = new int[1];
+ final java.util.Map methodsByDescriptor = new java.util.HashMap();
+ for (byte[] bytes : classes.values()) {
+ final int[] fields = new int[1];
+ new org.objectweb.asm.ClassReader(bytes).accept(
+ new org.objectweb.asm.ClassVisitor(org.objectweb.asm.Opcodes.ASM9) {
+ @Override
+ public org.objectweb.asm.FieldVisitor visitField(int a, String n, String d,
+ String s, Object v) {
+ fields[0]++;
+ return null;
+ }
+
+ @Override
+ public org.objectweb.asm.MethodVisitor visitMethod(int a, String n, String desc,
+ String s, String[] e) {
+ Integer c = methodsByDescriptor.get(desc);
+ methodsByDescriptor.put(desc, c == null ? 1 : c + 1);
+ return null;
+ }
+ }, org.objectweb.asm.ClassReader.SKIP_CODE | org.objectweb.asm.ClassReader.SKIP_DEBUG
+ | org.objectweb.asm.ClassReader.SKIP_FRAMES);
+ if (fields[0] > maxFields[0]) {
+ maxFields[0] = fields[0];
+ }
+ }
+ int maxMethodsPerDescriptor = 0;
+ for (int c : methodsByDescriptor.values()) {
+ if (c > maxMethodsPerDescriptor) {
+ maxMethodsPerDescriptor = c;
+ }
+ }
+ return Math.max(maxFields[0], maxMethodsPerDescriptor);
+ }
+
+ /** The {@code .java} a retrace synthesizes from an internal class name (its default). */
+ private static String defaultSourceFile(String internalName) {
+ String simple = internalName;
+ int slash = simple.lastIndexOf('/');
+ if (slash >= 0) {
+ simple = simple.substring(slash + 1);
+ }
+ int dollar = simple.indexOf('$');
+ if (dollar > 0) {
+ simple = simple.substring(0, dollar);
+ }
+ return simple + ".java";
+ }
+
+ /** Adds every Java/Kotlin identifier token in {@code source} to {@code out} (a safe over-set). */
+ private static void collectSourceIdentifiers(String source, java.util.Set out) {
+ int n = source.length();
+ int i = 0;
+ while (i < n) {
+ char c = source.charAt(i);
+ if (Character.isJavaIdentifierStart(c)) {
+ int start = i;
+ i++;
+ while (i < n && Character.isJavaIdentifierPart(source.charAt(i))) {
+ i++;
+ }
+ out.add(source.substring(start, i));
+ } else {
+ i++;
+ }
+ }
+ }
+
+ /**
+ * Collects every string literal ({@code LDC} operand or {@code static final String}
+ * {@code ConstantValue}) in every class of {@code jar} into {@code out}. Used to gather the
+ * literals of the unhardened library jars so an app value shared with a framework/dependency class
+ * is not encrypted (which would break a literal {@code ==} against the un-interned library copy on
+ * ParparVM). A jar that cannot be read is skipped -- a library the engine cannot scan simply
+ * contributes no exclusions rather than aborting the build.
+ */
+ private static void collectJarLiterals(File jar, java.util.Set out) {
+ if (jar == null || !jar.isFile()) {
+ return;
+ }
+ try {
+ java.io.FileInputStream fi = new java.io.FileInputStream(jar);
+ try {
+ java.util.zip.ZipInputStream zis = new java.util.zip.ZipInputStream(fi);
+ java.util.zip.ZipEntry entry;
+ byte[] buf = new byte[8192];
+ while ((entry = zis.getNextEntry()) != null) {
+ if (entry.isDirectory() || !entry.getName().endsWith(".class")) {
+ continue;
+ }
+ java.io.ByteArrayOutputStream bout = new java.io.ByteArrayOutputStream();
+ int r;
+ while ((r = zis.read(buf)) >= 0) {
+ bout.write(buf, 0, r);
+ }
+ try {
+ StringEncryptTransform.collectAllLiterals(bout.toByteArray(), out);
+ } catch (RuntimeException ignored) {
+ // A class ASM cannot parse (a newer format than this ASM, a malformed entry)
+ // contributes no exclusions; it must not fail the scan of the rest of the jar.
+ }
+ }
+ } finally {
+ fi.close();
+ }
+ } catch (IOException ex) {
+ // An unreadable library jar simply contributes no exclusions.
+ }
+ }
+
+ /**
+ * A classloader over the (renamed) application classes plus the library jars, for stack-map
+ * frame computation. JDK library classes resolve through the parent (bootstrap) loader, so the
+ * jmods are intentionally not added -- URLClassLoader can't read them and java.* resolves via
+ * the parent anyway. Never initializes classes (FrameClassWriter uses initialize=false).
+ */
+ private static ClassLoader buildHierarchyLoader(File hierarchyJar, List libraryJars) {
+ List urls = new ArrayList();
+ try {
+ if (hierarchyJar != null && hierarchyJar.isFile()) {
+ urls.add(hierarchyJar.toURI().toURL());
+ }
+ if (libraryJars != null) {
+ for (File lib : libraryJars) {
+ if (lib != null && lib.isFile()) {
+ urls.add(lib.toURI().toURL());
+ }
+ }
+ }
+ } catch (java.net.MalformedURLException e) {
+ return HardeningEngine.class.getClassLoader();
+ }
+ return new java.net.URLClassLoader(urls.toArray(new java.net.URL[urls.size()]),
+ HardeningEngine.class.getClassLoader());
+ }
+
+ /**
+ * Keep rules for every class named by a {@code META-INF/services/*} descriptor -- the service
+ * interface (the file name) and each provider class listed inside. The descriptors are carried
+ * across verbatim, so renaming any of these would break {@code ServiceLoader}.
+ */
+ private static List serviceDescriptorKeeps(JarDemuxer.NonClassEntries nonClass) {
+ List rules = new ArrayList();
+ java.util.Set seen = new java.util.HashSet();
+ String prefix = "META-INF/services/";
+ for (Map.Entry e : nonClass.asMap().entrySet()) {
+ String name = e.getKey();
+ if (!name.startsWith(prefix) || name.length() <= prefix.length()) {
+ continue;
+ }
+ addServiceKeep(rules, seen, name.substring(prefix.length()));
+ String body = new String(e.getValue(), java.nio.charset.Charset.forName("UTF-8"));
+ for (String line : body.split("\\r?\\n")) {
+ int hash = line.indexOf('#');
+ if (hash >= 0) {
+ line = line.substring(0, hash);
+ }
+ addServiceKeep(rules, seen, line.trim());
+ }
+ }
+ return rules;
+ }
+
+ private static void addServiceKeep(List rules, java.util.Set seen, String className) {
+ String c = className.trim();
+ if (c.length() == 0 || !isPlausibleClassName(c) || !seen.add(c)) {
+ return;
+ }
+ rules.add("-keep class " + c + " { *; }");
+ }
+
+ private static boolean isPlausibleClassName(String s) {
+ for (int i = 0; i < s.length(); i++) {
+ char c = s.charAt(i);
+ if (!Character.isJavaIdentifierPart(c) && c != '.' && c != '$') {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ private static int deriveSeed(HardeningConfig cfg, String buildKey) {
+ String basis = cfg.getSeed() != null ? cfg.getSeed()
+ : (buildKey == null || buildKey.isEmpty() ? "cn1-hardening" : buildKey);
+ int h = 0;
+ for (int i = 0; i < basis.length(); i++) {
+ h = h * 31 + basis.charAt(i);
+ }
+ return h;
+ }
+
+ private static int countRenamed(java.util.Set before, java.util.Set after) {
+ int n = 0;
+ for (String b : before) {
+ if (!after.contains(b)) {
+ n++;
+ }
+ }
+ return n;
+ }
+
+ private static void writeReport(File reportFile, HardeningConfig cfg, HardeningResult r)
+ throws IOException {
+ StringBuilder sb = new StringBuilder();
+ sb.append("{\n");
+ sb.append(" \"engine\": \"").append(ENGINE_VERSION).append("\",\n");
+ sb.append(" \"proguard\": \"").append(PROGUARD_VERSION).append("\",\n");
+ sb.append(" \"platform\": \"").append(json(cfg.getPlatform())).append("\",\n");
+ sb.append(" \"profile\": \"").append(cfg.getProfile().name().toLowerCase()).append("\",\n");
+ sb.append(" \"outcome\": \"").append(r.getOutcome().name()).append("\",\n");
+ sb.append(" \"classesIn\": ").append(r.getClassesIn()).append(",\n");
+ sb.append(" \"classesOut\": ").append(r.getClassesOut()).append(",\n");
+ sb.append(" \"renamedClasses\": ").append(r.getRenamedClasses()).append(",\n");
+ sb.append(" \"encryptedStrings\": ").append(r.getEncryptedStrings()).append(",\n");
+ sb.append(" \"mappingId\": \"").append(json(r.getMappingId())).append("\",\n");
+ sb.append(" \"transforms\": [");
+ List t = r.getTransformsApplied();
+ for (int i = 0; i < t.size(); i++) {
+ if (i > 0) {
+ sb.append(", ");
+ }
+ sb.append('"').append(json(t.get(i))).append('"');
+ }
+ sb.append("],\n");
+ // Serialize the warnings too: a warning records a known limitation (e.g. plaintext left in a
+ // JDK 9+ concat recipe, or a transform skipped as unsafe for the platform). A consumer reading
+ // the report -- not the forked-process log -- would otherwise see "transforms":["strings:all"]
+ // with no hint that some plaintext was excluded, which the doc promises the report surfaces.
+ sb.append(" \"warnings\": [");
+ List w = r.getWarnings();
+ for (int i = 0; i < w.size(); i++) {
+ if (i > 0) {
+ sb.append(", ");
+ }
+ sb.append('"').append(json(w.get(i))).append('"');
+ }
+ sb.append("]\n");
+ sb.append("}\n");
+ writeText(reportFile, sb.toString());
+ }
+
+ private static String json(String s) {
+ if (s == null) {
+ return "";
+ }
+ return s.replace("\\", "\\\\").replace("\"", "\\\"");
+ }
+
+ private static void writeText(File f, String text) throws IOException {
+ FileOutputStream fo = new FileOutputStream(f);
+ try {
+ Writer w = new OutputStreamWriter(fo, Charset.forName("UTF-8"));
+ w.write(text);
+ w.flush();
+ } finally {
+ fo.close();
+ }
+ }
+
+ private static void require(boolean cond, String message) throws HardeningException {
+ if (!cond) {
+ throw new HardeningException(message);
+ }
+ }
+}
diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningException.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningException.java
new file mode 100644
index 00000000000..c513ecc52b7
--- /dev/null
+++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningException.java
@@ -0,0 +1,34 @@
+/*
+ * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved.
+ * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
+ * This code is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License version 2 only, as
+ * published by the Free Software Foundation. Codename One designates this
+ * particular file as subject to the "Classpath" exception as provided
+ * by Oracle in the LICENSE file that accompanied this code.
+ *
+ * This code is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+ * version 2 for more details (a copy is included in the LICENSE file that
+ * accompanied this code).
+ *
+ * You should have received a copy of the GNU General Public License version
+ * 2 along with this work; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
+ *
+ * Please contact Codename One through http://www.codenameone.com/ if you
+ * need additional information or have any questions.
+ */
+package com.codename1.hardening;
+
+/** Thrown when hardening cannot complete and the build must fail rather than ship a half-hardened app. */
+public class HardeningException extends Exception {
+ public HardeningException(String message) {
+ super(message);
+ }
+
+ public HardeningException(String message, Throwable cause) {
+ super(message, cause);
+ }
+}
diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningProfile.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningProfile.java
new file mode 100644
index 00000000000..0541a9c34a7
--- /dev/null
+++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningProfile.java
@@ -0,0 +1,83 @@
+/*
+ * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved.
+ * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
+ * This code is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License version 2 only, as
+ * published by the Free Software Foundation. Codename One designates this
+ * particular file as subject to the "Classpath" exception as provided
+ * by Oracle in the LICENSE file that accompanied this code.
+ *
+ * This code is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+ * version 2 for more details (a copy is included in the LICENSE file that
+ * accompanied this code).
+ *
+ * You should have received a copy of the GNU General Public License version
+ * 2 along with this work; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
+ *
+ * Please contact Codename One through http://www.codenameone.com/ if you
+ * need additional information or have any questions.
+ */
+package com.codename1.hardening;
+
+/**
+ * The hardening level a build requested, from the {@code harden.level} build hint.
+ * The level is the primary control; individual {@code harden.*} switches override
+ * what a level turns on. Each level is a superset of the previous one.
+ */
+public enum HardeningProfile {
+ /** No transform runs; the input jar is returned untouched. */
+ OFF,
+ /** Class/method/field renaming plus encryption of constant strings. */
+ STANDARD,
+ /** Adds encryption of all strings and control-flow obfuscation (opaque predicates). */
+ AGGRESSIVE,
+ /**
+ * Raises control-flow obfuscation intensity on top of aggressive: two opaque-predicate
+ * guards per eligible method instead of one ({@link HardeningConfig#getControlFlowIntensity()}).
+ * It adds no new kind of transform -- in particular there is no reflective-name hiding, since
+ * Codename One has no runtime reflection for such names to feed.
+ */
+ PARANOID;
+
+ /** Parses a level name case-insensitively; returns {@code null} for an unknown value. */
+ public static HardeningProfile parse(String s) {
+ if (s == null) {
+ return null;
+ }
+ String v = s.trim().toUpperCase();
+ if (v.isEmpty()) {
+ return null;
+ }
+ for (HardeningProfile p : values()) {
+ if (p.name().equals(v)) {
+ return p;
+ }
+ }
+ return null;
+ }
+
+ public boolean isAtLeast(HardeningProfile other) {
+ return ordinal() >= other.ordinal();
+ }
+
+ /** Renaming applies at STANDARD and above. */
+ public boolean renamesByDefault() {
+ return isAtLeast(STANDARD);
+ }
+
+ /** STANDARD encrypts constant strings only; AGGRESSIVE and up encrypt all strings. */
+ public boolean encryptsAllStringsByDefault() {
+ return isAtLeast(AGGRESSIVE);
+ }
+
+ public boolean encryptsConstantStringsByDefault() {
+ return isAtLeast(STANDARD);
+ }
+
+ public boolean controlFlowByDefault() {
+ return isAtLeast(AGGRESSIVE);
+ }
+}
diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningRequest.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningRequest.java
new file mode 100644
index 00000000000..fd2370661da
--- /dev/null
+++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningRequest.java
@@ -0,0 +1,138 @@
+/*
+ * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved.
+ * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
+ * This code is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License version 2 only, as
+ * published by the Free Software Foundation. Codename One designates this
+ * particular file as subject to the "Classpath" exception as provided
+ * by Oracle in the LICENSE file that accompanied this code.
+ *
+ * This code is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+ * version 2 for more details (a copy is included in the LICENSE file that
+ * accompanied this code).
+ *
+ * You should have received a copy of the GNU General Public License version
+ * 2 along with this work; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
+ *
+ * Please contact Codename One through http://www.codenameone.com/ if you
+ * need additional information or have any questions.
+ */
+package com.codename1.hardening;
+
+import java.io.File;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Everything the engine needs for one run, assembled by the caller. No builder
+ * {@code BuildRequest} type crosses this boundary -- the plugin and the daemon each
+ * build one of these from their own request object and their resolved hint map, so
+ * the engine stays single-sourced across the two repositories.
+ */
+public final class HardeningRequest {
+ private File inputJar;
+ private File outputJar;
+ private File mappingFile;
+ private File reportFile;
+ private File r8KeepFile;
+ private File workDir;
+ private HardeningConfig config;
+ private String mainClass;
+ private String buildKey = "";
+ private final List libraryJars = new ArrayList();
+
+ public File getInputJar() {
+ return inputJar;
+ }
+
+ public HardeningRequest inputJar(File f) {
+ this.inputJar = f;
+ return this;
+ }
+
+ public File getOutputJar() {
+ return outputJar;
+ }
+
+ public HardeningRequest outputJar(File f) {
+ this.outputJar = f;
+ return this;
+ }
+
+ public File getMappingFile() {
+ return mappingFile;
+ }
+
+ public HardeningRequest mappingFile(File f) {
+ this.mappingFile = f;
+ return this;
+ }
+
+ public File getReportFile() {
+ return reportFile;
+ }
+
+ public HardeningRequest reportFile(File f) {
+ this.reportFile = f;
+ return this;
+ }
+
+ public File getR8KeepFile() {
+ return r8KeepFile;
+ }
+
+ public HardeningRequest r8KeepFile(File f) {
+ this.r8KeepFile = f;
+ return this;
+ }
+
+ public File getWorkDir() {
+ return workDir;
+ }
+
+ public HardeningRequest workDir(File f) {
+ this.workDir = f;
+ return this;
+ }
+
+ public HardeningConfig getConfig() {
+ return config;
+ }
+
+ public HardeningRequest config(HardeningConfig c) {
+ this.config = c;
+ return this;
+ }
+
+ public String getMainClass() {
+ return mainClass;
+ }
+
+ public HardeningRequest mainClass(String s) {
+ this.mainClass = s;
+ return this;
+ }
+
+ public String getBuildKey() {
+ return buildKey;
+ }
+
+ public HardeningRequest buildKey(String s) {
+ this.buildKey = s == null ? "" : s;
+ return this;
+ }
+
+ public List getLibraryJars() {
+ return libraryJars;
+ }
+
+ public HardeningRequest addLibraryJar(File f) {
+ if (f != null) {
+ libraryJars.add(f);
+ }
+ return this;
+ }
+}
diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningResult.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningResult.java
new file mode 100644
index 00000000000..a53784fb8fe
--- /dev/null
+++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/HardeningResult.java
@@ -0,0 +1,126 @@
+/*
+ * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved.
+ * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
+ * This code is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License version 2 only, as
+ * published by the Free Software Foundation. Codename One designates this
+ * particular file as subject to the "Classpath" exception as provided
+ * by Oracle in the LICENSE file that accompanied this code.
+ *
+ * This code is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+ * version 2 for more details (a copy is included in the LICENSE file that
+ * accompanied this code).
+ *
+ * You should have received a copy of the GNU General Public License version
+ * 2 along with this work; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
+ *
+ * Please contact Codename One through http://www.codenameone.com/ if you
+ * need additional information or have any questions.
+ */
+package com.codename1.hardening;
+
+import java.io.File;
+import java.util.ArrayList;
+import java.util.List;
+
+/** Outcome of a hardening run. When skipped, {@link #getHardenedJar()} is the original input. */
+public final class HardeningResult {
+
+ public enum Outcome {
+ HARDENED,
+ SKIPPED_NOT_REQUESTED,
+ SKIPPED_PLATFORM_DISABLED
+ }
+
+ private final Outcome outcome;
+ private final File hardenedJar;
+ private final File mappingFile;
+ private final List warnings = new ArrayList();
+ private final List transformsApplied = new ArrayList();
+ private int classesIn;
+ private int classesOut;
+ private int renamedClasses;
+ private int encryptedStrings;
+ private String mappingId = "";
+
+ private HardeningResult(Outcome outcome, File hardenedJar, File mappingFile) {
+ this.outcome = outcome;
+ this.hardenedJar = hardenedJar;
+ this.mappingFile = mappingFile;
+ }
+
+ public static HardeningResult skipped(Outcome outcome, File inputJar) {
+ return new HardeningResult(outcome, inputJar, null);
+ }
+
+ public static HardeningResult hardened(File hardenedJar, File mappingFile) {
+ return new HardeningResult(Outcome.HARDENED, hardenedJar, mappingFile);
+ }
+
+ public Outcome getOutcome() {
+ return outcome;
+ }
+
+ public boolean isHardened() {
+ return outcome == Outcome.HARDENED;
+ }
+
+ public File getHardenedJar() {
+ return hardenedJar;
+ }
+
+ public File getMappingFile() {
+ return mappingFile;
+ }
+
+ public List getWarnings() {
+ return warnings;
+ }
+
+ public List getTransformsApplied() {
+ return transformsApplied;
+ }
+
+ public int getClassesIn() {
+ return classesIn;
+ }
+
+ public void setClassesIn(int classesIn) {
+ this.classesIn = classesIn;
+ }
+
+ public int getClassesOut() {
+ return classesOut;
+ }
+
+ public void setClassesOut(int classesOut) {
+ this.classesOut = classesOut;
+ }
+
+ public int getRenamedClasses() {
+ return renamedClasses;
+ }
+
+ public void setRenamedClasses(int renamedClasses) {
+ this.renamedClasses = renamedClasses;
+ }
+
+ public int getEncryptedStrings() {
+ return encryptedStrings;
+ }
+
+ public void setEncryptedStrings(int encryptedStrings) {
+ this.encryptedStrings = encryptedStrings;
+ }
+
+ public String getMappingId() {
+ return mappingId;
+ }
+
+ public void setMappingId(String mappingId) {
+ this.mappingId = mappingId;
+ }
+}
diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/InputJarKeepScanner.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/InputJarKeepScanner.java
new file mode 100644
index 00000000000..1cac10a8cd0
--- /dev/null
+++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/InputJarKeepScanner.java
@@ -0,0 +1,116 @@
+/*
+ * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved.
+ * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
+ * This code is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License version 2 only, as
+ * published by the Free Software Foundation. Codename One designates this
+ * particular file as subject to the "Classpath" exception as provided
+ * by Oracle in the LICENSE file that accompanied this code.
+ *
+ * This code is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+ * version 2 for more details (a copy is included in the LICENSE file that
+ * accompanied this code).
+ *
+ * You should have received a copy of the GNU General Public License version
+ * 2 along with this work; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
+ *
+ * Please contact Codename One through http://www.codenameone.com/ if you
+ * need additional information or have any questions.
+ */
+package com.codename1.hardening;
+
+import java.util.ArrayList;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import org.objectweb.asm.ClassReader;
+import org.objectweb.asm.ClassVisitor;
+import org.objectweb.asm.Opcodes;
+
+/**
+ * Tier 2 keep rules, derived from the input classes with ASM. Codename One has no reflection --
+ * {@code Class.forName} never resolved an obfuscated app class -- so there is nothing to keep for a
+ * class named only by a string. What ProGuard cannot infer declaratively is the naming
+ * convention that binds a native interface to its generated peer: for a native interface
+ * {@code com.foo.Bar} the build produces {@code com.foo.BarImpl} / {@code com.foo.BarStub} and
+ * resolves them by name. This scanner finds the native interfaces (phase 1) and keeps exactly those
+ * peers (phase 2), which is far narrower than the previous {@code **Impl} / {@code **Stub}.
+ */
+public final class InputJarKeepScanner {
+
+ private static final String NATIVE_INTERFACE = "com/codename1/system/NativeInterface";
+
+ /** internal name -> its direct super-interfaces (from the class's interfaces[]). */
+ private final java.util.Map interfacesOf =
+ new java.util.HashMap();
+ private final Set nativeInterfaceTypes = new LinkedHashSet();
+
+ /** Scans every class in {@code classesByInternalName} (keyed {@code a/b/C}). */
+ public void scan(Map classesByInternalName) {
+ for (byte[] classBytes : classesByInternalName.values()) {
+ ClassReader cr = new ClassReader(classBytes);
+ cr.accept(new HierarchyCollector(), ClassReader.SKIP_CODE | ClassReader.SKIP_DEBUG
+ | ClassReader.SKIP_FRAMES);
+ }
+ // A type is a native interface if NativeInterface is in its transitive super-interface
+ // closure. Resolve transitively across the classes we saw (an interface may extend another
+ // native interface rather than NativeInterface directly).
+ for (String type : interfacesOf.keySet()) {
+ if (extendsNativeInterface(type, new LinkedHashSet())) {
+ nativeInterfaceTypes.add(type);
+ }
+ }
+ }
+
+ private boolean extendsNativeInterface(String type, Set visiting) {
+ if (!visiting.add(type)) {
+ return false;
+ }
+ String[] ifaces = interfacesOf.get(type);
+ if (ifaces == null) {
+ return false;
+ }
+ for (String i : ifaces) {
+ if (NATIVE_INTERFACE.equals(i) || extendsNativeInterface(i, visiting)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /** The derived keep rules: the generated {@code Impl}/{@code Stub} peer of each native interface. */
+ public List keepRules() {
+ List rules = new ArrayList();
+ for (String type : nativeInterfaceTypes) {
+ String dotted = type.replace('/', '.');
+ rules.add("-keep class " + dotted + "Impl { *; }");
+ rules.add("-keep class " + dotted + "Stub { *; }");
+ }
+ return rules;
+ }
+
+ /** Visible for testing: the native interface types found in the input (dotted names). */
+ List nativeInterfaces() {
+ List out = new ArrayList();
+ for (String type : nativeInterfaceTypes) {
+ out.add(type.replace('/', '.'));
+ }
+ return out;
+ }
+
+ private final class HierarchyCollector extends ClassVisitor {
+ HierarchyCollector() {
+ super(Opcodes.ASM9);
+ }
+
+ @Override
+ public void visit(int version, int access, String name, String signature,
+ String superName, String[] interfaces) {
+ interfacesOf.put(name, interfaces == null ? new String[0] : interfaces);
+ }
+ }
+}
diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/JarDemuxer.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/JarDemuxer.java
new file mode 100644
index 00000000000..79993aa46da
--- /dev/null
+++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/JarDemuxer.java
@@ -0,0 +1,221 @@
+/*
+ * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved.
+ * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
+ * This code is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License version 2 only, as
+ * published by the Free Software Foundation. Codename One designates this
+ * particular file as subject to the "Classpath" exception as provided
+ * by Oracle in the LICENSE file that accompanied this code.
+ *
+ * This code is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+ * version 2 for more details (a copy is included in the LICENSE file that
+ * accompanied this code).
+ *
+ * You should have received a copy of the GNU General Public License version
+ * 2 along with this work; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
+ *
+ * Please contact Codename One through http://www.codenameone.com/ if you
+ * need additional information or have any questions.
+ */
+package com.codename1.hardening;
+
+import java.io.BufferedOutputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipInputStream;
+import java.util.zip.ZipOutputStream;
+
+/**
+ * Splits the merged application jar into a class-only jar (the only thing ProGuard
+ * and the ASM transforms ever see) and an ordered set of every non-class entry.
+ * The non-class entries -- {@code .aar}, {@code .a}, {@code .res} theme files,
+ * tarred native bundles, resources -- are carried across byte-for-byte and
+ * re-emitted into the hardened jar. Running ProGuard over the whole jar would
+ * recompress and mangle those, which is why the split exists.
+ */
+public final class JarDemuxer {
+
+ /** The non-class entries of an input jar, kept in their original order and bytes. */
+ public static final class NonClassEntries {
+ private final Map entries = new LinkedHashMap();
+
+ void put(String name, byte[] data) {
+ entries.put(name, data);
+ }
+
+ public int size() {
+ return entries.size();
+ }
+
+ public Map asMap() {
+ return entries;
+ }
+ }
+
+ private JarDemuxer() {
+ }
+
+ /**
+ * Reads {@code input}, writes every {@code .class} entry into {@code classesJarOut}, and
+ * returns the remaining entries. Directory entries are dropped (the rebuild recreates the
+ * container).
+ *
+ * @return the non-class entries plus, via {@link #classCount}, how many classes were split
+ */
+ public static NonClassEntries split(File input, File classesJarOut) throws IOException {
+ NonClassEntries nonClass = new NonClassEntries();
+ FileInputStream fi = new FileInputStream(input);
+ try {
+ ZipInputStream zis = new ZipInputStream(fi);
+ FileOutputStream fo = new FileOutputStream(classesJarOut);
+ try {
+ ZipOutputStream zos = new ZipOutputStream(new BufferedOutputStream(fo));
+ ZipEntry entry;
+ while ((entry = zis.getNextEntry()) != null) {
+ if (entry.isDirectory()) {
+ continue;
+ }
+ byte[] data = readAll(zis);
+ String name = entry.getName();
+ if (name.endsWith(".class")) {
+ ZipEntry out = new ZipEntry(name);
+ zos.putNextEntry(out);
+ zos.write(data);
+ zos.closeEntry();
+ } else if (isJarSignature(name)) {
+ // Renaming/transforming classes invalidates any bundled jar signature, so
+ // carrying the .SF/.RSA/.DSA/.EC blocks across would make a verifying JarFile
+ // throw SecurityException: Invalid signature file digest. Drop them; without
+ // the .SF the JVM no longer verifies, which is correct for a rewritten jar.
+ continue;
+ } else {
+ // Every non-class entry -- resources, native bundles, AND any .java/.kt/.swift/
+ // .m/.h/.cs source a CN1Lib bundles -- is carried byte-for-byte. Carrying a
+ // .java/.kt source unchanged while the engine renames classes is SAFE and cannot
+ // produce a "cannot find symbol" against the renamed jar, because no builder ever
+ // javac-compiles an unzip'd source against an engine-renamed classpath:
+ // - Android: the engine does NOT rename (AndroidGradleBuilder.hardeningRename-
+ // Supported()==false -> renameEnabled=false). R8 is the sole renamer and runs
+ // AFTER javac has compiled the app classes together with any bundled native
+ // sources, so R8 renames the whole set consistently -- imports still resolve.
+ // - iOS/mac/watch/tv: unzip routes these sources into the RESOURCE tree
+ // (IPhoneBuilder passes resDir as unzip's sourceDir); the only javac compiles
+ // the generated stub dir, never the carried sources. Native impls are Obj-C.
+ // - win/linux: javac compiles only the ParparVM translator-GENERATED .java,
+ // emitted from already-renamed bytecode, so it is internally consistent.
+ // - javascript: javac targets the JS port's own sources against staged classes;
+ // bundled app native sources are .js, and string encryption is off on JS.
+ // - javase/desktop: runs the bytecode directly and recompiles no sources.
+ // So there is no rename-vs-source mismatch to guard; a source-text keep scanner
+ // would be dead code. Revisit only if a builder starts compiling unzip's sourceDir
+ // against an engine-renamed classpath.
+ //
+ // A resource entry is copied under its ORIGINAL path, so a PACKAGE-RELATIVE
+ // Class.getResourceAsStream("x.properties") from an engine-renamed class -- which the
+ // runtime resolves under the class's now-obfuscated package -- would miss it. This is
+ // a DELIBERATE design choice, not an oversight: Codename One's own resource model
+ // uses absolute paths (Display.getResourceAsStream("/x"), the theme .res), which are
+ // unaffected by package renaming, so package names are obfuscated on purpose (see the
+ // BuiltinKeepRules packageNamesAreNotKept test). The only exposure is a bundled
+ // third-party dependency that loads a package-relative resource; adapting resource
+ // paths by the class mapping cannot run here (the mapping does not exist until after
+ // ProGuard) and would forfeit package obfuscation. Such a dependency uses
+ // harden.keep to keep its package-relative-resource classes (which keeps their
+ // package path); this limitation is documented in App-Hardening.asciidoc.
+ nonClass.put(name, data);
+ }
+ }
+ zos.finish();
+ zos.flush();
+ } finally {
+ fo.close();
+ }
+ } finally {
+ fi.close();
+ }
+ return nonClass;
+ }
+
+ /**
+ * Writes {@code outJar} from the transformed classes (keyed by internal name, e.g.
+ * {@code a/b/C}) plus the preserved non-class entries, each copied byte-for-byte.
+ */
+ public static void rebuild(File outJar, Map classesByInternalName,
+ NonClassEntries nonClass) throws IOException {
+ FileOutputStream fo = new FileOutputStream(outJar);
+ try {
+ ZipOutputStream zos = new ZipOutputStream(new BufferedOutputStream(fo));
+ for (Map.Entry e : classesByInternalName.entrySet()) {
+ ZipEntry entry = new ZipEntry(e.getKey() + ".class");
+ zos.putNextEntry(entry);
+ zos.write(e.getValue());
+ zos.closeEntry();
+ }
+ for (Map.Entry e : nonClass.asMap().entrySet()) {
+ ZipEntry entry = new ZipEntry(e.getKey());
+ zos.putNextEntry(entry);
+ zos.write(e.getValue());
+ zos.closeEntry();
+ }
+ zos.finish();
+ zos.flush();
+ } finally {
+ fo.close();
+ }
+ }
+
+ /** Reads every {@code .class} entry of a jar into a map keyed by internal name. */
+ public static Map readClasses(File jar) throws IOException {
+ Map classes = new LinkedHashMap();
+ FileInputStream fi = new FileInputStream(jar);
+ try {
+ ZipInputStream zis = new ZipInputStream(fi);
+ ZipEntry entry;
+ while ((entry = zis.getNextEntry()) != null) {
+ if (entry.isDirectory() || !entry.getName().endsWith(".class")) {
+ continue;
+ }
+ byte[] data = readAll(zis);
+ String internal = entry.getName().substring(0, entry.getName().length() - ".class".length());
+ classes.put(internal, data);
+ }
+ } finally {
+ fi.close();
+ }
+ return classes;
+ }
+
+ /** True for a jar signature block under META-INF that a class rewrite invalidates. */
+ static boolean isJarSignature(String name) {
+ String upper = name.toUpperCase();
+ if (!upper.startsWith("META-INF/")) {
+ return false;
+ }
+ // Only the signature blocks in META-INF's top level, not nested paths.
+ if (upper.indexOf('/', "META-INF/".length()) >= 0) {
+ return false;
+ }
+ return upper.endsWith(".SF") || upper.endsWith(".RSA") || upper.endsWith(".DSA")
+ || upper.endsWith(".EC");
+ }
+
+ private static byte[] readAll(InputStream in) throws IOException {
+ ByteArrayOutputStream bout = new ByteArrayOutputStream(Math.max(1024, in.available()));
+ byte[] buf = new byte[8192];
+ int r;
+ while ((r = in.read(buf)) >= 0) {
+ bout.write(buf, 0, r);
+ }
+ return bout.toByteArray();
+ }
+}
diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/Main.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/Main.java
new file mode 100644
index 00000000000..e78d609d304
--- /dev/null
+++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/Main.java
@@ -0,0 +1,198 @@
+/*
+ * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved.
+ * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
+ * This code is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License version 2 only, as
+ * published by the Free Software Foundation. Codename One designates this
+ * particular file as subject to the "Classpath" exception as provided
+ * by Oracle in the LICENSE file that accompanied this code.
+ *
+ * This code is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+ * version 2 for more details (a copy is included in the LICENSE file that
+ * accompanied this code).
+ *
+ * You should have received a copy of the GNU General Public License version
+ * 2 along with this work; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
+ *
+ * Please contact Codename One through http://www.codenameone.com/ if you
+ * need additional information or have any questions.
+ */
+package com.codename1.hardening;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Properties;
+
+/**
+ * Command-line front end. The engine runs as a forked process so it is single-sourced
+ * across the maven plugin and the cloud daemon and cannot drift between them, and so
+ * its ProGuard/ASM never share a classloader with either caller.
+ *
+ *
+ * java -jar cn1-hardening.jar harden --in in.jar --out out.jar \
+ * --mapping mapping.txt --report report.json --config config.properties
+ *
+ *
+ * The {@code config.properties} carries the resolved {@code harden.*} hints plus
+ * {@code cn1.platform}, {@code cn1.mainClass}, {@code cn1.renameSupported},
+ * {@code cn1.entitled}, {@code cn1.buildKey} and {@code cn1.libraryJars}. Exit codes:
+ * {@code 0} hardened, {@code 3} declined by config (caller keeps the input jar),
+ * {@code 4} not entitled, anything else a failure.
+ */
+public final class Main {
+
+ public static final int EXIT_HARDENED = 0;
+ public static final int EXIT_FAILED = 1;
+ public static final int EXIT_DECLINED = 3;
+ public static final int EXIT_NOT_ENTITLED = 4;
+
+ private Main() {
+ }
+
+ public static void main(String[] args) {
+ System.exit(run(args));
+ }
+
+ static int run(String[] args) {
+ try {
+ if (args.length == 0 || !"harden".equals(args[0])) {
+ System.err.println("usage: harden --in --out --mapping "
+ + "--report [--r8keep ] --config ");
+ return EXIT_FAILED;
+ }
+ Map opts = parseOptions(args);
+ File in = fileOpt(opts, "in");
+ File out = fileOpt(opts, "out");
+ File mapping = fileOpt(opts, "mapping");
+ File report = opts.containsKey("report") ? new File(opts.get("report")) : null;
+ File r8Keep = opts.containsKey("r8keep") ? new File(opts.get("r8keep")) : null;
+ File configFile = fileOpt(opts, "config");
+
+ Properties props = new Properties();
+ FileInputStream fi = new FileInputStream(configFile);
+ try {
+ props.load(fi);
+ } finally {
+ fi.close();
+ }
+
+ String platform = props.getProperty("cn1.platform", "unknown");
+ String mainClass = props.getProperty("cn1.mainClass", "");
+ boolean renameSupported = !"false".equalsIgnoreCase(props.getProperty("cn1.renameSupported", "true"));
+ boolean entitled = !"false".equalsIgnoreCase(props.getProperty("cn1.entitled", "true"));
+ String buildKey = props.getProperty("cn1.buildKey", "");
+
+ Map hints = new HashMap();
+ for (String name : props.stringPropertyNames()) {
+ if (name.startsWith("harden.")) {
+ hints.put(name, props.getProperty(name));
+ }
+ }
+
+ // An unrecognized harden.level must fail loudly rather than be treated as off (which
+ // would ship an unhardened binary the developer believes is hardened).
+ String rawLevel = hints.get("harden.level");
+ if (rawLevel != null && rawLevel.trim().length() > 0
+ && !"off".equalsIgnoreCase(rawLevel.trim())
+ && HardeningProfile.parse(rawLevel) == null) {
+ System.err.println("Invalid harden.level '" + rawLevel + "'. Valid values are: "
+ + "off, standard, aggressive, paranoid.");
+ return EXIT_FAILED;
+ }
+ // An unrecognized harden.strings must fail rather than silently enabling the most
+ // invasive ("all") mode on a typo.
+ String rawStrings = hints.get("harden.strings");
+ if (rawStrings != null && rawStrings.trim().length() > 0) {
+ String s = rawStrings.trim().toLowerCase();
+ if (!"off".equals(s) && !"constants".equals(s) && !"all".equals(s)) {
+ System.err.println("Invalid harden.strings '" + rawStrings + "'. Valid values "
+ + "are: off, constants, all.");
+ return EXIT_FAILED;
+ }
+ }
+
+ HardeningConfig cfg = HardeningConfig.from(hints, platform, renameSupported);
+
+ // Gate entitlement only when a transform will actually run: a per-platform opt-out, or a
+ // level whose transforms are all individually disabled, must produce an unhardened build
+ // even on a non-entitled account (the engine would return SKIPPED), rather than failing.
+ if (HardeningEngine.willApplyAnyTransform(cfg) && !entitled) {
+ System.err.println("App hardening is an Enterprise feature and this build is not "
+ + "entitled. Refusing to produce a half-hardened binary.");
+ return EXIT_NOT_ENTITLED;
+ }
+
+ HardeningRequest req = new HardeningRequest()
+ .inputJar(in)
+ .outputJar(out)
+ .mappingFile(mapping)
+ .reportFile(report)
+ .r8KeepFile(r8Keep)
+ .workDir(out.getAbsoluteFile().getParentFile())
+ .config(cfg)
+ .mainClass(mainClass)
+ .buildKey(buildKey);
+ for (File lib : libraryJars(props)) {
+ req.addLibraryJar(lib);
+ }
+
+ HardeningResult result = HardeningEngine.harden(req);
+ if (!result.isHardened()) {
+ System.out.println("cn1-hardening: skipped (" + result.getOutcome() + ")");
+ return EXIT_DECLINED;
+ }
+ System.out.println("cn1-hardening: hardened " + result.getClassesOut() + " classes, "
+ + "renamed " + result.getRenamedClasses() + ", encrypted "
+ + result.getEncryptedStrings() + " strings, transforms="
+ + result.getTransformsApplied() + ", mappingId=" + result.getMappingId());
+ for (String w : result.getWarnings()) {
+ System.out.println("cn1-hardening: warning: " + w);
+ }
+ return EXIT_HARDENED;
+ } catch (HardeningException e) {
+ System.err.println("cn1-hardening: " + e.getMessage());
+ return EXIT_FAILED;
+ } catch (Exception e) {
+ System.err.println("cn1-hardening: unexpected failure: " + e);
+ e.printStackTrace();
+ return EXIT_FAILED;
+ }
+ }
+
+ private static java.util.List libraryJars(Properties props) {
+ java.util.List jars = new java.util.ArrayList();
+ String raw = props.getProperty("cn1.libraryJars", "");
+ if (raw != null && !raw.isEmpty()) {
+ for (String p : raw.split(java.util.regex.Pattern.quote(File.pathSeparator))) {
+ if (!p.trim().isEmpty()) {
+ jars.add(new File(p.trim()));
+ }
+ }
+ }
+ return jars;
+ }
+
+ private static Map parseOptions(String[] args) {
+ Map opts = new HashMap();
+ for (int i = 1; i < args.length - 1; i++) {
+ if (args[i].startsWith("--")) {
+ opts.put(args[i].substring(2), args[i + 1]);
+ i++;
+ }
+ }
+ return opts;
+ }
+
+ private static File fileOpt(Map opts, String key) throws HardeningException {
+ String v = opts.get(key);
+ if (v == null) {
+ throw new HardeningException("missing --" + key);
+ }
+ return new File(v);
+ }
+}
diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/MangleCollisionCheck.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/MangleCollisionCheck.java
new file mode 100644
index 00000000000..80563c18168
--- /dev/null
+++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/MangleCollisionCheck.java
@@ -0,0 +1,72 @@
+/*
+ * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved.
+ * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
+ * This code is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License version 2 only, as
+ * published by the Free Software Foundation. Codename One designates this
+ * particular file as subject to the "Classpath" exception as provided
+ * by Oracle in the LICENSE file that accompanied this code.
+ *
+ * This code is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+ * version 2 for more details (a copy is included in the LICENSE file that
+ * accompanied this code).
+ *
+ * You should have received a copy of the GNU General Public License version
+ * 2 along with this work; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
+ *
+ * Please contact Codename One through http://www.codenameone.com/ if you
+ * need additional information or have any questions.
+ */
+package com.codename1.hardening;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * Guards against a ParparVM name collision. The translator mangles a Java class
+ * name to a C symbol by turning {@code '.'}, {@code '/'} and {@code '$'} into
+ * {@code '_'} ({@code ByteCodeClass}), so two distinct classes whose names differ
+ * only in those separators -- {@code a.b_c} and {@code a.b.c} -- collapse to the
+ * same C symbol {@code a_b_c} and the native build fails confusingly. The
+ * {@link Cn1NameFactory} dictionary never emits {@code '_'}, so a collision should
+ * be impossible; this check makes that a guarantee rather than an assumption.
+ */
+public final class MangleCollisionCheck {
+
+ private MangleCollisionCheck() {
+ }
+
+ /**
+ * @param internalNames output class names in internal form ({@code a/b/C})
+ * @throws HardeningException naming the two classes that collide
+ */
+ public static void check(Set internalNames) throws HardeningException {
+ Map byMangled = new HashMap();
+ for (String name : internalNames) {
+ String mangled = mangle(name);
+ String prev = byMangled.put(mangled, name);
+ if (prev != null) {
+ throw new HardeningException("Obfuscated class names '" + prev + "' and '" + name
+ + "' both mangle to the ParparVM C symbol '" + mangled
+ + "'. This would break the native build.");
+ }
+ }
+ }
+
+ static String mangle(String internalName) {
+ StringBuilder b = new StringBuilder(internalName.length());
+ for (int i = 0; i < internalName.length(); i++) {
+ char c = internalName.charAt(i);
+ if (c == '/' || c == '.' || c == '$') {
+ b.append('_');
+ } else {
+ b.append(c);
+ }
+ }
+ return b.toString();
+ }
+}
diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/MappingWriter.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/MappingWriter.java
new file mode 100644
index 00000000000..a370bbe3f53
--- /dev/null
+++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/MappingWriter.java
@@ -0,0 +1,172 @@
+/*
+ * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved.
+ * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
+ * This code is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License version 2 only, as
+ * published by the Free Software Foundation. Codename One designates this
+ * particular file as subject to the "Classpath" exception as provided
+ * by Oracle in the LICENSE file that accompanied this code.
+ *
+ * This code is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+ * version 2 for more details (a copy is included in the LICENSE file that
+ * accompanied this code).
+ *
+ * You should have received a copy of the GNU General Public License version
+ * 2 along with this work; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
+ *
+ * Please contact Codename One through http://www.codenameone.com/ if you
+ * need additional information or have any questions.
+ */
+package com.codename1.hardening;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.nio.charset.Charset;
+import java.nio.file.Files;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+
+/**
+ * Finalizes the ProGuard-format mapping: prepends a provenance header (engine and
+ * ProGuard versions, platform, build key) so a support ticket can say exactly which
+ * engine produced it, and computes the {@code mappingId} -- the SHA-256 of the
+ * mapping body -- stamped into the app so a crash report can be tied to the exact
+ * mapping even when a rebuild reuses the build key.
+ */
+public final class MappingWriter {
+
+ private MappingWriter() {
+ }
+
+ /**
+ * Prepends the header to {@code mappingFile} in place and returns its {@code mappingId}
+ * computed over the ProGuard body (excluding the header, so the id is stable regardless of
+ * header text).
+ */
+ public static String finalizeMapping(File mappingFile, String engineVersion, String proguardVersion,
+ String platform, String buildKey) throws HardeningException {
+ try {
+ byte[] body = mappingFile.isFile()
+ ? Files.readAllBytes(mappingFile.toPath())
+ : new byte[0];
+ String mappingId = sha256Hex(body);
+ StringBuilder header = new StringBuilder();
+ header.append("# Codename One App Hardening mapping\n");
+ header.append("# engine: ").append(engineVersion).append('\n');
+ header.append("# proguard: ").append(proguardVersion).append('\n');
+ header.append("# platform: ").append(platform).append('\n');
+ header.append("# buildKey: ").append(buildKey == null ? "" : buildKey).append('\n');
+ header.append("# mappingId: ").append(mappingId).append('\n');
+ FileOutputStream fo = new FileOutputStream(mappingFile);
+ try {
+ OutputStream out = fo;
+ out.write(header.toString().getBytes(Charset.forName("UTF-8")));
+ out.write(body);
+ out.flush();
+ } finally {
+ fo.close();
+ }
+ return mappingId;
+ } catch (IOException e) {
+ throw new HardeningException("Could not finalize the mapping file", e);
+ }
+ }
+
+ /**
+ * Injects R8-style {@code sourceFile} metadata comments into the mapping so a retrace can report the
+ * real source filename. The engine strips the {@code SourceFile} attribute and ProGuard's mapping
+ * records no filename, so without this a Kotlin class ({@code Screen.kt}) or a package-private class
+ * declared in a differently named file retraces to a synthesized {@code .java} that does not
+ * exist. Only classes whose recorded filename differs from that synthesized default are written (the
+ * ordinary {@code Foo}/{@code Foo.java} case needs no comment). The comment is INDENTED so the retrace
+ * parser attaches it to the preceding class line, matching R8's own emission.
+ *
+ * @param sourceFileByFqcn original dotted class name to its {@code SourceFile}, for the classes worth
+ * recording; captured before the attribute was stripped.
+ */
+ static void injectSourceFiles(File mappingFile, java.util.Map sourceFileByFqcn)
+ throws HardeningException {
+ if (sourceFileByFqcn == null || sourceFileByFqcn.isEmpty() || mappingFile == null
+ || !mappingFile.isFile()) {
+ return;
+ }
+ try {
+ java.util.List lines = Files.readAllLines(mappingFile.toPath(),
+ Charset.forName("UTF-8"));
+ StringBuilder out = new StringBuilder();
+ for (String line : lines) {
+ out.append(line).append('\n');
+ // A class line is unindented, contains " -> ", and ends with ':'. Its members follow
+ // indented, so inject the metadata comment right after it (also indented).
+ if (!line.isEmpty() && !Character.isWhitespace(line.charAt(0)) && line.endsWith(":")) {
+ int arrow = line.indexOf(" -> ");
+ if (arrow > 0) {
+ String original = line.substring(0, arrow).trim();
+ String sf = sourceFileByFqcn.get(original);
+ if (sf != null) {
+ out.append(" # {\"id\":\"sourceFile\",\"fileName\":\"")
+ .append(jsonEscape(sf)).append("\"}\n");
+ }
+ }
+ }
+ }
+ Files.write(mappingFile.toPath(), out.toString().getBytes(Charset.forName("UTF-8")));
+ } catch (IOException e) {
+ throw new HardeningException("Could not inject source-file metadata into the mapping", e);
+ }
+ }
+
+ /**
+ * Escapes a string for a JSON value. Besides {@code \} and {@code "}, a control character (newline,
+ * tab, ...) must be escaped too: the metadata is a single mapping-comment line, so a raw newline would
+ * split it and a raw control char is invalid JSON, either of which stops the reader from recovering the
+ * filename. Reachable for a Kotlin or package-private Java class stored in an unusually named file.
+ */
+ private static String jsonEscape(String s) {
+ StringBuilder b = new StringBuilder(s.length() + 8);
+ for (int i = 0; i < s.length(); i++) {
+ char c = s.charAt(i);
+ switch (c) {
+ case '\\': b.append("\\\\"); break;
+ case '"': b.append("\\\""); break;
+ case '\b': b.append("\\b"); break;
+ case '\f': b.append("\\f"); break;
+ case '\n': b.append("\\n"); break;
+ case '\r': b.append("\\r"); break;
+ case '\t': b.append("\\t"); break;
+ default:
+ if (c < 0x20) {
+ b.append("\\u");
+ String hex = Integer.toHexString(c);
+ for (int p = hex.length(); p < 4; p++) {
+ b.append('0');
+ }
+ b.append(hex);
+ } else {
+ b.append(c);
+ }
+ }
+ }
+ return b.toString();
+ }
+
+ static String sha256Hex(byte[] data) throws HardeningException {
+ try {
+ MessageDigest md = MessageDigest.getInstance("SHA-256");
+ byte[] digest = md.digest(data);
+ StringBuilder sb = new StringBuilder(digest.length * 2);
+ for (byte b : digest) {
+ sb.append(Character.forDigit((b >> 4) & 0xf, 16));
+ sb.append(Character.forDigit(b & 0xf, 16));
+ }
+ return sb.toString();
+ } catch (NoSuchAlgorithmException e) {
+ throw new HardeningException("SHA-256 unavailable", e);
+ }
+ }
+}
diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/MethodSize.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/MethodSize.java
new file mode 100644
index 00000000000..1df4ba72011
--- /dev/null
+++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/MethodSize.java
@@ -0,0 +1,116 @@
+/*
+ * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved.
+ * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
+ * This code is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License version 2 only, as
+ * published by the Free Software Foundation. Codename One designates this
+ * particular file as subject to the "Classpath" exception as provided
+ * by Oracle in the LICENSE file that accompanied this code.
+ *
+ * This code is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+ * version 2 for more details (a copy is included in the LICENSE file that
+ * accompanied this code).
+ *
+ * You should have received a copy of the GNU General Public License version
+ * 2 along with this work; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
+ *
+ * Please contact Codename One through http://www.codenameone.com/ if you
+ * need additional information or have any questions.
+ */
+package com.codename1.hardening;
+
+import org.objectweb.asm.tree.AbstractInsnNode;
+import org.objectweb.asm.tree.InsnList;
+import org.objectweb.asm.tree.LookupSwitchInsnNode;
+import org.objectweb.asm.tree.MethodNode;
+import org.objectweb.asm.tree.TableSwitchInsnNode;
+
+/**
+ * Conservative UPPER-BOUND estimate of a method's encoded {@code Code} size in bytes, so a transform
+ * can refuse to grow a method past the JVM's 65,535-byte limit (which makes ASM throw
+ * {@code MethodTooLargeException} at write time and abort an otherwise-valid build). Every instruction
+ * is charged its widest legal encoding, so the estimate never undercounts -- a transform that stops
+ * short of {@link #SAFE_LIMIT} is safe even though ASM might have squeezed a little more in.
+ */
+final class MethodSize {
+
+ /** The hard JVM limit on a method's bytecode array. */
+ static final int LIMIT = 65535;
+ /**
+ * The constant-pool item count a transform must stay under. The hard JVM limit is 65535 entries;
+ * this sits below it by a margin that absorbs the per-item estimates a transform makes before it
+ * knows the exact pool growth. Shared by the string and control-flow transforms.
+ */
+ static final int SAFE_POOL_ITEMS = 60000;
+ /**
+ * The size a transform must stay under. Below {@link #LIMIT} by a margin that absorbs both the
+ * upper-bound estimate's slack and the fact that the real limit is on the emitted bytes, which
+ * COMPUTE_MAXS/FRAMES can shift slightly.
+ */
+ static final int SAFE_LIMIT = 60000;
+
+ private MethodSize() {
+ }
+
+ /** Upper-bound encoded byte size of {@code m}'s instructions, or 0 when it has none. */
+ static int estimateBytes(MethodNode m) {
+ return m == null || m.instructions == null ? 0 : estimateBytes(m.instructions);
+ }
+
+ /** Upper-bound encoded byte size of an instruction list. */
+ static int estimateBytes(InsnList insns) {
+ int total = 0;
+ for (AbstractInsnNode n = insns.getFirst(); n != null; n = n.getNext()) {
+ total += estimateBytes(n);
+ }
+ return total;
+ }
+
+ /** Upper-bound encoded byte size of a single instruction node (0 for labels/line/frame metadata). */
+ static int estimateBytes(AbstractInsnNode n) {
+ switch (n.getType()) {
+ case AbstractInsnNode.LABEL:
+ case AbstractInsnNode.LINE:
+ case AbstractInsnNode.FRAME:
+ return 0;
+ case AbstractInsnNode.INSN:
+ return 1;
+ case AbstractInsnNode.INT_INSN:
+ return 3;
+ case AbstractInsnNode.VAR_INSN:
+ return 4;
+ case AbstractInsnNode.TYPE_INSN:
+ return 3;
+ case AbstractInsnNode.FIELD_INSN:
+ return 3;
+ case AbstractInsnNode.METHOD_INSN:
+ return 5;
+ case AbstractInsnNode.INVOKE_DYNAMIC_INSN:
+ return 5;
+ case AbstractInsnNode.JUMP_INSN:
+ // GOTO/JSR have a 5-byte wide form (GOTO_W/JSR_W). A conditional branch (IF*) has NO wide
+ // form, so when its target is out of the +-32KB range ASM widens it to an inverted 3-byte
+ // conditional over a 5-byte GOTO_W == 8 bytes. Charge the widest form of each so the
+ // upper-bound estimate never undercounts a method dense with long-range conditionals.
+ return n.getOpcode() == org.objectweb.asm.Opcodes.GOTO
+ || n.getOpcode() == org.objectweb.asm.Opcodes.JSR ? 5 : 8;
+ case AbstractInsnNode.LDC_INSN:
+ return 3;
+ case AbstractInsnNode.IINC_INSN:
+ return 6;
+ case AbstractInsnNode.TABLESWITCH_INSN:
+ // opcode + up to 3 pad + default/low/high (12) + one 4-byte offset per case.
+ return 16 + ((TableSwitchInsnNode) n).labels.size() * 4;
+ case AbstractInsnNode.LOOKUPSWITCH_INSN:
+ // opcode + up to 3 pad + default/npairs (8) + one 8-byte (match,offset) per case.
+ return 12 + ((LookupSwitchInsnNode) n).keys.size() * 8;
+ case AbstractInsnNode.MULTIANEWARRAY_INSN:
+ return 4;
+ default:
+ return 4;
+ }
+ }
+}
diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/OutputVerifier.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/OutputVerifier.java
new file mode 100644
index 00000000000..9ca890eff48
--- /dev/null
+++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/OutputVerifier.java
@@ -0,0 +1,144 @@
+/*
+ * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved.
+ * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
+ * This code is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License version 2 only, as
+ * published by the Free Software Foundation. Codename One designates this
+ * particular file as subject to the "Classpath" exception as provided
+ * by Oracle in the LICENSE file that accompanied this code.
+ *
+ * This code is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+ * version 2 for more details (a copy is included in the LICENSE file that
+ * accompanied this code).
+ *
+ * You should have received a copy of the GNU General Public License version
+ * 2 along with this work; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
+ *
+ * Please contact Codename One through http://www.codenameone.com/ if you
+ * need additional information or have any questions.
+ */
+package com.codename1.hardening;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import org.objectweb.asm.ClassReader;
+import org.objectweb.asm.ClassWriter;
+import org.objectweb.asm.Opcodes;
+import org.objectweb.asm.Type;
+import org.objectweb.asm.tree.ClassNode;
+import org.objectweb.asm.tree.MethodNode;
+import org.objectweb.asm.tree.analysis.Analyzer;
+import org.objectweb.asm.tree.analysis.BasicValue;
+import org.objectweb.asm.tree.analysis.SimpleVerifier;
+import org.objectweb.asm.util.CheckClassAdapter;
+
+/**
+ * Verifies every class the engine is about to ship. A transform bug that produces
+ * invalid bytecode must fail the build here, not at first launch on a device.
+ * Each class gets a structural check ({@code CheckClassAdapter}, no type loading)
+ * plus per-method data-flow analysis ({@code SimpleVerifier}); the data-flow pass
+ * runs method-by-method so a method that references a target-only type absent from
+ * the supplied jars can be tolerated without masking a genuine error elsewhere in
+ * the same class.
+ */
+public final class OutputVerifier {
+
+ private OutputVerifier() {
+ }
+
+ /**
+ * @param hierarchy a classloader over the (renamed) input classes plus the library jars, so the
+ * verifier's {@code SimpleVerifier} resolves application types instead of
+ * loading them from the engine's own classpath (which would fail verification
+ * on any class with a merge between application types). May be {@code null}.
+ * @throws HardeningException on the first class that fails verification, naming it.
+ */
+ public static void verify(Map classesByInternalName, ClassLoader hierarchy)
+ throws HardeningException {
+ for (Map.Entry e : classesByInternalName.entrySet()) {
+ // Structural checks first (visit order, access flags, names/descriptors); these load no types,
+ // so a structurally-invalid transform output is always caught.
+ verifyStructureOnly(e.getKey(), e.getValue());
+ // Then data-flow, PER METHOD, rather than through CheckClassAdapter.verify's whole-class report.
+ // A method that fails only because an absent target-only type cannot be resolved is tolerated,
+ // but WITHOUT masking a genuine data-flow error in another method of the same class: ASM can
+ // append both diagnostics to one report, where a substring check would misclassify the whole
+ // report as an unresolved-type failure and discard the real error.
+ verifyDataFlow(e.getKey(), e.getValue(), hierarchy);
+ }
+ }
+
+ /**
+ * Data-flow verification, one method at a time, so the missing-type tolerance is scoped to the method
+ * that actually references the absent type. A method whose analysis fails only because a type is absent
+ * from the supplied jars is accepted (FrameClassWriter computed its frames from the bytes and the
+ * target JVM verifies it on-device); any other analyzer failure fails the build, naming the method.
+ */
+ private static void verifyDataFlow(String name, byte[] classBytes, ClassLoader hierarchy)
+ throws HardeningException {
+ ClassNode cn = new ClassNode();
+ new ClassReader(classBytes).accept(cn, 0);
+ Type currentClass = Type.getObjectType(cn.name);
+ Type superClass = cn.superName == null ? null : Type.getObjectType(cn.superName);
+ List interfaces = new ArrayList();
+ if (cn.interfaces != null) {
+ for (String i : cn.interfaces) {
+ interfaces.add(Type.getObjectType(i));
+ }
+ }
+ boolean isInterface = (cn.access & Opcodes.ACC_INTERFACE) != 0;
+ if (cn.methods == null) {
+ return;
+ }
+ for (MethodNode m : cn.methods) {
+ if (m.instructions == null || m.instructions.size() == 0) {
+ continue; // abstract or native: no body to analyze
+ }
+ SimpleVerifier verifier = new SimpleVerifier(currentClass, superClass, interfaces, isInterface);
+ verifier.setClassLoader(hierarchy);
+ try {
+ new Analyzer(verifier).analyze(cn.name, m);
+ } catch (Throwable t) {
+ if (isUnresolvedTypeFailure(t)) {
+ continue;
+ }
+ throw new HardeningException("Hardened class '" + name + "' method '" + m.name + m.desc
+ + "' failed bytecode verification: " + t.getMessage(), t);
+ }
+ }
+ }
+
+ /**
+ * Structural verification only (no data-flow, so no type loading): checks the class-file structure --
+ * visit order, valid access flags, names and descriptors. Used as the fallback when the data-flow
+ * verifier cannot resolve an absent type.
+ */
+ private static void verifyStructureOnly(String name, byte[] classBytes) throws HardeningException {
+ try {
+ new ClassReader(classBytes).accept(new CheckClassAdapter(new ClassWriter(0), false), 0);
+ } catch (Throwable t) {
+ throw new HardeningException("Hardened class '" + name
+ + "' failed structural bytecode verification: " + t.getMessage(), t);
+ }
+ }
+
+ /** True when a verification failure is only a missing type (absent from the supplied jars), not a defect. */
+ private static boolean isUnresolvedTypeFailure(Throwable t) {
+ Throwable c = t;
+ for (int guard = 0; c != null && guard < 32; guard++) {
+ if (c instanceof TypeNotPresentException || c instanceof ClassNotFoundException
+ || c instanceof NoClassDefFoundError) {
+ return true;
+ }
+ if (c == c.getCause()) {
+ break;
+ }
+ c = c.getCause();
+ }
+ return false;
+ }
+}
diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/ProGuardRunner.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/ProGuardRunner.java
new file mode 100644
index 00000000000..8657301283c
--- /dev/null
+++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/ProGuardRunner.java
@@ -0,0 +1,182 @@
+/*
+ * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved.
+ * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
+ * This code is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License version 2 only, as
+ * published by the Free Software Foundation. Codename One designates this
+ * particular file as subject to the "Classpath" exception as provided
+ * by Oracle in the LICENSE file that accompanied this code.
+ *
+ * This code is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+ * version 2 for more details (a copy is included in the LICENSE file that
+ * accompanied this code).
+ *
+ * You should have received a copy of the GNU General Public License version
+ * 2 along with this work; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
+ *
+ * Please contact Codename One through http://www.codenameone.com/ if you
+ * need additional information or have any questions.
+ */
+package com.codename1.hardening;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.OutputStreamWriter;
+import java.io.Writer;
+import java.nio.charset.Charset;
+import java.util.ArrayList;
+import java.util.List;
+import proguard.Configuration;
+import proguard.ConfigurationParser;
+import proguard.ProGuard;
+
+/**
+ * Drives ProGuard 7.3.x programmatically over the class-only jar to rename it and
+ * emit the mapping. Everything is expressed as a generated {@code .pro} config, the
+ * best-understood ProGuard interface. Shrinking and optimization are always off --
+ * ParparVM culls and R8 shrinks, and enabling them here only risks release-only
+ * breakage.
+ */
+public final class ProGuardRunner {
+
+ private ProGuardRunner() {
+ }
+
+ /**
+ * Renames {@code classesJar} into {@code outJar} and writes {@code mappingFile}.
+ *
+ * @param libraryJars the app's compile-scope libraries and port jars, so overrides are not
+ * misrenamed; the JRE is added automatically
+ * @param keepRules the assembled Tier 1-3 keep rules
+ * @param dictionary the {@link Cn1NameFactory} dictionary used for classes, members and packages
+ */
+ public static void rename(File classesJar, File outJar, File mappingFile,
+ List libraryJars, List keepRules, File dictionary,
+ File workDir, String platform) throws HardeningException {
+ File config = new File(workDir, "cn1-hardening.pro");
+ try {
+ writeConfig(config, classesJar, outJar, mappingFile, libraryJars, keepRules, dictionary, platform);
+ } catch (IOException e) {
+ throw new HardeningException("Could not write ProGuard configuration", e);
+ }
+
+ Configuration configuration = new Configuration();
+ ConfigurationParser parser = null;
+ try {
+ parser = new ConfigurationParser(config, System.getProperties());
+ parser.parse(configuration);
+ } catch (Exception e) {
+ throw new HardeningException("ProGuard configuration is invalid: " + e.getMessage(), e);
+ } finally {
+ close(parser);
+ }
+
+ try {
+ new ProGuard(configuration).execute();
+ } catch (Exception e) {
+ throw new HardeningException("ProGuard failed while renaming the application: "
+ + e.getMessage(), e);
+ }
+ if (!outJar.isFile()) {
+ throw new HardeningException("ProGuard did not produce an output jar");
+ }
+ }
+
+ private static void writeConfig(File config, File classesJar, File outJar, File mappingFile,
+ List libraryJars, List keepRules, File dictionary,
+ String platform)
+ throws IOException {
+ FileOutputStream fo = new FileOutputStream(config);
+ try {
+ Writer w = new OutputStreamWriter(fo, Charset.forName("UTF-8"));
+ w.write("-injars " + quote(classesJar) + "\n");
+ w.write("-outjars " + quote(outJar) + "\n");
+ for (File lib : runtimeLibraryJars()) {
+ w.write("-libraryjars " + quote(lib) + "\n");
+ }
+ if (libraryJars != null) {
+ for (File lib : libraryJars) {
+ if (lib != null && lib.exists()) {
+ w.write("-libraryjars " + quote(lib) + "\n");
+ }
+ }
+ }
+ w.write("-printmapping " + quote(mappingFile) + "\n");
+ w.write("-classobfuscationdictionary " + quote(dictionary) + "\n");
+ w.write("-obfuscationdictionary " + quote(dictionary) + "\n");
+ w.write("-packageobfuscationdictionary " + quote(dictionary) + "\n");
+ for (String flag : BuiltinKeepRules.flags(platform)) {
+ w.write(flag + "\n");
+ }
+ if (keepRules != null) {
+ for (String rule : keepRules) {
+ w.write(rule + "\n");
+ }
+ }
+ w.flush();
+ } finally {
+ fo.close();
+ }
+ }
+
+ /** rt.jar on a JDK 8 runtime, or every jmod on a JDK 9+ runtime. */
+ static List runtimeLibraryJars() {
+ List jars = new ArrayList();
+ String javaHome = System.getProperty("java.home");
+ if (javaHome == null) {
+ return jars;
+ }
+ File home = new File(javaHome);
+ File rt = new File(home, "lib/rt.jar");
+ if (rt.isFile()) {
+ jars.add(rt);
+ File jce = new File(home, "lib/jce.jar");
+ if (jce.isFile()) {
+ jars.add(jce);
+ }
+ return jars;
+ }
+ File jmods = new File(home, "jmods");
+ File[] mods = jmods.listFiles();
+ if (mods != null) {
+ for (File m : mods) {
+ if (m.getName().endsWith(".jmod")) {
+ jars.add(m);
+ }
+ }
+ }
+ return jars;
+ }
+
+ static String quote(File f) {
+ String path = f.getAbsolutePath();
+ // ProGuard reads a quoted file name until the matching close quote and does not support escaping
+ // inside it, so a path containing the quote character cannot use that quote. Pick the quote the
+ // path does not contain -- a single quote/apostrophe (e.g. /home/o'brien/app.jar) forces double
+ // quotes. This is exactly what ProGuard's own ConfigurationParser accepts for such names.
+ if (path.indexOf('\'') < 0) {
+ return "'" + path + "'";
+ }
+ if (path.indexOf('"') < 0) {
+ return "\"" + path + "\"";
+ }
+ // A path containing BOTH a single and a double quote is unrepresentable to the ProGuard parser
+ // (essentially never a real filesystem path); fail clearly rather than emit invalid config.
+ throw new IllegalArgumentException("A hardening classpath entry cannot be passed to ProGuard "
+ + "because its path contains both a single and a double quote: " + path);
+ }
+
+ private static void close(ConfigurationParser parser) {
+ if (parser != null) {
+ try {
+ parser.close();
+ } catch (IOException ignore) {
+ // best effort
+ }
+ }
+ }
+}
diff --git a/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java b/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java
new file mode 100644
index 00000000000..c6c246402d6
--- /dev/null
+++ b/maven/cn1-hardening/src/main/java/com/codename1/hardening/StringEncryptTransform.java
@@ -0,0 +1,1637 @@
+/*
+ * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved.
+ * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
+ * This code is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License version 2 only, as
+ * published by the Free Software Foundation. Codename One designates this
+ * particular file as subject to the "Classpath" exception as provided
+ * by Oracle in the LICENSE file that accompanied this code.
+ *
+ * This code is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+ * version 2 for more details (a copy is included in the LICENSE file that
+ * accompanied this code).
+ *
+ * You should have received a copy of the GNU General Public License version
+ * 2 along with this work; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
+ *
+ * Please contact Codename One through http://www.codenameone.com/ if you
+ * need additional information or have any questions.
+ */
+package com.codename1.hardening;
+
+import java.util.List;
+import org.objectweb.asm.ClassReader;
+import org.objectweb.asm.ClassWriter;
+import org.objectweb.asm.ConstantDynamic;
+import org.objectweb.asm.Handle;
+import org.objectweb.asm.Opcodes;
+import org.objectweb.asm.Type;
+import org.objectweb.asm.tree.AbstractInsnNode;
+import org.objectweb.asm.tree.AnnotationNode;
+import org.objectweb.asm.tree.ClassNode;
+import org.objectweb.asm.tree.FieldInsnNode;
+import org.objectweb.asm.tree.FieldNode;
+import org.objectweb.asm.tree.InsnList;
+import org.objectweb.asm.tree.InsnNode;
+import org.objectweb.asm.tree.IntInsnNode;
+import org.objectweb.asm.tree.InvokeDynamicInsnNode;
+import org.objectweb.asm.tree.LdcInsnNode;
+import org.objectweb.asm.tree.MethodInsnNode;
+import org.objectweb.asm.tree.MethodNode;
+import org.objectweb.asm.tree.VarInsnNode;
+
+/**
+ * Encrypts the string literals in a class so they are not readable in the shipped
+ * binary, and are never present as plaintext in the ParparVM C constant pool.
+ *
+ * Two channels are neutralized, which is the point often missed: the
+ * {@code LDC "..."} literals in method bodies, and the {@code ConstantValue}
+ * attribute of {@code static final String} fields. javac inlines a constant into
+ * every reader as its own LDC (caught by the first channel), but the defining
+ * field still carries the plaintext in its {@code ConstantValue} slot, which
+ * ParparVM emits into the same C table -- so we also strip that attribute and move
+ * the initialization into {@code } as a decode call.
+ *
+ * The decoder is synthesized into each class with a per-class key baked in, so
+ * there is no single named framework method to hook. (Scattering, split keys and
+ * inlining are further hardening layers the design calls for; a per-class keyed
+ * decoder already removes the single-hook weakness and is what ships first.)
+ */
+public final class StringEncryptTransform {
+
+ /** Synthesized per-class decoder; the {@code $} keeps it clear of any real app member. */
+ static final String DECODER_NAME = "zqdec$";
+ private static final String HOISTED_FIELD_PREFIX = "zqL$";
+ static final String DECODER_DESC = "(Ljava/lang/String;)Ljava/lang/String;";
+
+ /** {@code StringConcatFactory} recipe markers: an ordinary argument slot and a constant slot. */
+ private static final char TAG_ARG = '\u0001';
+ private static final char TAG_CONST = '\u0002';
+
+ /** Synthesized initializer-chunk helpers, kept clear of any real member. */
+ private static final String INIT_HELPER_PREFIX = "zqCI$";
+ /**
+ * Cut a generated into helper methods once a chunk grows past this many ENCODED bytes, so
+ * a class with thousands of hoisted/encrypted constants never exceeds the JVM's 65535-byte method
+ * limit. Kept well under MethodSize.SAFE_LIMIT so each helper fits.
+ */
+ private static final int MAX_CLINIT_CHUNK_BYTES = 48000;
+ /** Widest encoding of one INVOKESTATIC helper call prepended to . */
+ private static final int CLINIT_CALL_BYTES = 5;
+ /** Widest encoding of the per-access decoder INVOKESTATIC inserted after an LDC. */
+ private static final int DECODER_CALL_BYTES = 5;
+ /**
+ * Conservative constant-pool entries each hoisted literal adds -- a Utf8 for the field name, a
+ * NameAndType, a Fieldref, and the ciphertext Utf8 + String -- so the total can be bounded before
+ * hoisting rather than discovering the overflow only when ASM writes the class.
+ */
+ private static final int POOL_ITEMS_PER_HOIST = 6;
+ /**
+ * Conservative constant-pool entries encrypting one {@code static final String} adds -- the field's
+ * {@code Fieldref} and {@code NameAndType} for the new {@code PUTSTATIC} (the ciphertext replaces the
+ * stripped plaintext, so it is roughly net-zero). Budgeted with the hoisting growth against the pool.
+ */
+ private static final int POOL_ITEMS_PER_STATIC = 4;
+ /**
+ * Conservative constant-pool entries one per-access ciphertext adds -- a Utf8 for the ciphertext
+ * plus its {@code String_info}. The per-access channel is NOT pool-neutral: when the plaintext value
+ * is retained elsewhere (an unstripped static-final {@code ConstantValue}, an annotation value, or
+ * another class in the jar), the original pair stays referenced, so the ciphertext pair is a net
+ * addition. Budgeted per distinct value against the pool so a class dense with retained plaintext
+ * cannot silently overflow the 65535-entry limit in {@code ClassWriter.toByteArray()}.
+ */
+ private static final int POOL_ITEMS_PER_ACCESS = 2;
+ /**
+ * Conservative constant-pool entries the synthetic decoder itself adds (its name/descriptor, the
+ * {@code Methodref} the encrypt sites share, and the {@code String.toCharArray}/{@code intern}
+ * references). Reserved from the pool budget, and gated: a class whose pool cannot fit even this
+ * fixed overhead cannot be encrypted at all.
+ */
+ private static final int DECODER_POOL_OVERHEAD = 32;
+
+ private final boolean encryptAllStrings;
+ private final int seed;
+ private final ClassLoader hierarchy;
+ private final java.util.Set constantValues;
+ /**
+ * Values that must be left plaintext in EVERY class (a jar-wide exclusion). When a value cannot be
+ * encrypted in some class (a method too full for the per-access call, or a class whose pool cannot
+ * fit the decoder), encrypting it in the OTHER classes would break a valid literal {@code ==} on
+ * ParparVM (the decoded copy is interned, the plaintext copy is not). The engine collects these in a
+ * first pass and re-runs with them excluded so a value is encrypted everywhere or nowhere.
+ */
+ private final java.util.Set jarExcluded;
+ /** Values this transform left plaintext for a size/pool reason, for the engine to exclude jar-wide. */
+ private final java.util.Set newlyExcluded = new java.util.HashSet();
+ private int encryptedCount;
+ private int concatLiteralCount;
+ private int legacyInterfaceConstantCount;
+ private int oversizedLiteralCount;
+ private int condyLiteralCount;
+ private int clinitFullLiteralCount;
+ private int methodFullLiteralCount;
+ private int annotationLiteralCount;
+ private int indyLiteralCount;
+ private int shortLiteralCount;
+ /** True when this class was left UNHARDENED because its frame hierarchy could not be resolved. */
+ private boolean hierarchyIncompleteSkipped;
+ /** The input class's constant-pool item count, so hoisting can stay under the 65535-entry limit. */
+ private int poolBaseItems;
+ /**
+ * Constant-pool items still available before the 65535-entry limit, after reserving the decoder's
+ * fixed overhead. Both channels (hoisted method literals and static-final constants) draw from it,
+ * so their combined growth is budgeted rather than each ignoring the other.
+ */
+ private int poolItemsRemaining;
+
+ public StringEncryptTransform(boolean encryptAllStrings, int seed) {
+ this(encryptAllStrings, seed, null, null, null);
+ }
+
+ public StringEncryptTransform(boolean encryptAllStrings, int seed, ClassLoader hierarchy) {
+ this(encryptAllStrings, seed, hierarchy, null, null);
+ }
+
+ /**
+ * @param hierarchy a classloader over the (renamed) input classes plus the library jars,
+ * used for stack-map frame computation so it never loads types through the
+ * engine's own classloader; may be {@code null} in tests
+ * @param constantValues in "constants" mode ({@code encryptAllStrings == false}), the set of
+ * string values that were declared as {@code static final String}
+ * constants across the jar; only those literals (including javac's inlined
+ * copies at every read site) are encrypted. Ignored in "all" mode. May be
+ * {@code null}, in which case constants mode encrypts nothing extra.
+ */
+ public StringEncryptTransform(boolean encryptAllStrings, int seed, ClassLoader hierarchy,
+ java.util.Set constantValues) {
+ this(encryptAllStrings, seed, hierarchy, constantValues, null);
+ }
+
+ /**
+ * @param jarExcluded values to force-leave plaintext in this class (a jar-wide exclusion collected
+ * by the engine so a value is encrypted everywhere or nowhere); may be
+ * {@code null}. See {@link #getNewlyExcluded()}.
+ */
+ public StringEncryptTransform(boolean encryptAllStrings, int seed, ClassLoader hierarchy,
+ java.util.Set constantValues, java.util.Set jarExcluded) {
+ this.encryptAllStrings = encryptAllStrings;
+ this.seed = seed;
+ this.hierarchy = hierarchy;
+ this.constantValues = constantValues;
+ this.jarExcluded = jarExcluded;
+ }
+
+ /**
+ * Values this transform left plaintext for a method-size or constant-pool reason. The engine unions
+ * these across the jar and re-runs the transform with them excluded, so a value that cannot be
+ * encrypted in one class is left plaintext in ALL classes -- keeping a valid literal {@code ==} on
+ * ParparVM (where a decoded literal is interned but a compile-time literal is not).
+ */
+ public java.util.Set getNewlyExcluded() {
+ return newlyExcluded;
+ }
+
+ /** Collects the values of {@code static final String} fields in {@code classBytes} into {@code out}. */
+ public static void collectConstantValues(byte[] classBytes, final java.util.Set out) {
+ new ClassReader(classBytes).accept(new org.objectweb.asm.ClassVisitor(Opcodes.ASM9) {
+ @Override
+ public org.objectweb.asm.FieldVisitor visitField(int access, String name, String desc,
+ String sig, Object value) {
+ if ((access & Opcodes.ACC_STATIC) != 0 && (access & Opcodes.ACC_FINAL) != 0
+ && value instanceof String) {
+ out.add((String) value);
+ }
+ return null;
+ }
+ }, ClassReader.SKIP_CODE | ClassReader.SKIP_DEBUG | ClassReader.SKIP_FRAMES);
+ }
+
+ /**
+ * Collects every string a class contributes to ParparVM's constant pool as an interned-free literal
+ * -- {@code LDC} operands and {@code static final String} {@code ConstantValue}s. Used to gather the
+ * literals of the UNHARDENED library jars: on a ParparVM-C target a compile-time literal is a
+ * constant-pool object that is never interned, while an encrypted app copy is {@code intern()}ed, so
+ * a value encrypted in the app but left plaintext in a library class would compare {@code !=} against
+ * the library copy even though the two equal literals were reference-equal before hardening. The
+ * engine excludes these from encryption so that identity is preserved across the library boundary.
+ */
+ public static void collectAllLiterals(byte[] classBytes, final java.util.Set out) {
+ new ClassReader(classBytes).accept(new org.objectweb.asm.ClassVisitor(Opcodes.ASM9) {
+ @Override
+ public org.objectweb.asm.FieldVisitor visitField(int access, String name, String desc,
+ String sig, Object value) {
+ if (value instanceof String) {
+ out.add((String) value);
+ }
+ return null;
+ }
+
+ @Override
+ public org.objectweb.asm.MethodVisitor visitMethod(int access, String name, String desc,
+ String sig, String[] exceptions) {
+ return new org.objectweb.asm.MethodVisitor(Opcodes.ASM9) {
+ @Override
+ public void visitLdcInsn(Object cst) {
+ if (cst instanceof String) {
+ out.add((String) cst);
+ }
+ }
+ };
+ }
+ }, ClassReader.SKIP_DEBUG | ClassReader.SKIP_FRAMES);
+ }
+
+ /**
+ * Values that appear as plaintext literals in the UNHARDENED library jars. On a ParparVM-C target
+ * encrypting an app copy of one of these would break a valid literal {@code ==} against the library's
+ * (never interned) constant-pool copy, so they are excluded from encryption. Null when there is no
+ * such constraint (a real-JVM/Android target, where every compile-time literal is interned anyway).
+ */
+ private java.util.Set libraryLiterals;
+ /** Distinct values this transform left plaintext because a library class also holds them as a literal. */
+ private final java.util.Set libraryExcludedValues = new java.util.HashSet();
+
+ /** Sets the unhardened-library literal set whose values must stay plaintext to preserve identity. */
+ void setLibraryLiterals(java.util.Set values) {
+ this.libraryLiterals = values;
+ }
+
+ /** The distinct values left plaintext because an unhardened library class also holds them as a literal. */
+ java.util.Set getLibraryExcludedValues() {
+ return libraryExcludedValues;
+ }
+
+ /**
+ * Simple field names a carried {@code .java}/{@code .kt} source (bundled in the app jar and compiled
+ * downstream against the transformed classes, e.g. an Android CN1Lib native source) may reference as a
+ * compile-time constant. A {@code static final String} whose name is in this set keeps its
+ * {@code ConstantValue} attribute: stripping it would make the field a non-constant and break a
+ * {@code case}/annotation/const-initializer reference in that source at javac/kotlinc time. Null on a
+ * target that does not compile carried source, where the attribute is stripped as usual.
+ */
+ private java.util.Set sourceReferencedNames;
+ /** Count of static-final constants whose ConstantValue was preserved for a carried source reference. */
+ private int sourcePreservedConstantCount;
+
+ /** Sets the field names carried source may reference as constants, whose ConstantValue is preserved. */
+ void setSourceReferencedNames(java.util.Set names) {
+ this.sourceReferencedNames = names;
+ }
+
+ /** "owner/name" of every static String field read by a GETSTATIC across the jar (non-inlined reads). */
+ private java.util.Set externallyReadStaticFields;
+ /** Count of static-final constants whose ConstantValue was preserved because a GETSTATIC reads them. */
+ private int externallyReadConstantCount;
+
+ /**
+ * Sets the fully-qualified ({@code owner/name}) static String fields that are read somewhere in the
+ * jar by a {@code GETSTATIC} rather than an inlined {@code LDC}; their {@code ConstantValue} must not
+ * be migrated to {@code } (it would change reentrant-initialization ordering).
+ */
+ void setExternallyReadStaticFields(java.util.Set fields) {
+ this.externallyReadStaticFields = fields;
+ }
+
+ /**
+ * Collects into {@code out} the {@code owner/name} of every static {@code String} field read by a
+ * {@code GETSTATIC} in {@code classBytes}. A compile-time String constant is normally inlined by
+ * javac/kotlinc, so a surviving {@code GETSTATIC} means a non-inlined read whose declaring field's
+ * {@code ConstantValue} must be preserved rather than moved into {@code }.
+ *
+ * The symbolic owner of a {@code GETSTATIC} may be a SUBCLASS that inherits the field ({@code
+ * GETSTATIC C.X} for a field {@code X} declared by superclass {@code B} is valid). The transform keys
+ * on the DECLARING class, so the owner is resolved to it through {@code hierarchy}; if it cannot be
+ * resolved, the reference owner is recorded as a safe over-approximation.
+ */
+ public static void collectGetStaticStringReads(byte[] classBytes, final java.util.Set out,
+ final ClassLoader hierarchy) {
+ new ClassReader(classBytes).accept(new org.objectweb.asm.ClassVisitor(Opcodes.ASM9) {
+ @Override
+ public org.objectweb.asm.MethodVisitor visitMethod(int access, String name, String desc,
+ String sig, String[] ex) {
+ return new org.objectweb.asm.MethodVisitor(Opcodes.ASM9) {
+ @Override
+ public void visitFieldInsn(int opcode, String owner, String fname, String fdesc) {
+ if (opcode == Opcodes.GETSTATIC && "Ljava/lang/String;".equals(fdesc)) {
+ out.add(resolveDeclaringClass(hierarchy, owner, fname) + "." + fname);
+ }
+ }
+ };
+ }
+ }, ClassReader.SKIP_DEBUG | ClassReader.SKIP_FRAMES);
+ }
+
+ /**
+ * Walks {@code owner}'s superclass chain through {@code hierarchy} to the class that actually declares
+ * a static {@code String} field named {@code fieldName} (JVM field resolution binds an inherited
+ * {@code GETSTATIC} to that class). Returns {@code owner} unchanged when the hierarchy is unavailable
+ * or the declaring class cannot be read.
+ */
+ private static String resolveDeclaringClass(ClassLoader hierarchy, String owner, final String fieldName) {
+ if (hierarchy == null) {
+ return owner;
+ }
+ String c = owner;
+ java.util.Set seen = new java.util.HashSet();
+ while (c != null && seen.add(c)) {
+ final boolean[] declaresHere = {false};
+ final String[] superName = {null};
+ java.io.InputStream in = hierarchy.getResourceAsStream(c + ".class");
+ if (in == null) {
+ break;
+ }
+ try {
+ new ClassReader(in).accept(new org.objectweb.asm.ClassVisitor(Opcodes.ASM9) {
+ @Override
+ public void visit(int v, int a, String name, String sig, String sup, String[] itf) {
+ superName[0] = sup;
+ }
+
+ @Override
+ public org.objectweb.asm.FieldVisitor visitField(int access, String name, String desc,
+ String sig, Object value) {
+ if ((access & Opcodes.ACC_STATIC) != 0 && fieldName.equals(name)
+ && "Ljava/lang/String;".equals(desc)) {
+ declaresHere[0] = true;
+ }
+ return null;
+ }
+ }, ClassReader.SKIP_CODE | ClassReader.SKIP_DEBUG | ClassReader.SKIP_FRAMES);
+ } catch (Throwable t) {
+ break;
+ } finally {
+ try {
+ in.close();
+ } catch (Throwable ignore) {
+ // best effort
+ }
+ }
+ if (declaresHere[0]) {
+ return c;
+ }
+ c = superName[0];
+ }
+ return owner;
+ }
+
+ /** Count of static-final String constants left plaintext to keep a carried source's compilation valid. */
+ int getSourcePreservedConstantCount() {
+ return sourcePreservedConstantCount;
+ }
+
+ /** Count of static-final constants left plaintext because a GETSTATIC (non-inlined) read observes them. */
+ int getExternallyReadConstantCount() {
+ return externallyReadConstantCount;
+ }
+
+ public int getEncryptedCount() {
+ return encryptedCount;
+ }
+
+ /**
+ * The number of {@code invokedynamic} string-concatenation sites carrying plaintext literal text
+ * that this transform did NOT encrypt. javac from JDK 9 on compiles {@code "a" + b} to an
+ * {@code invokedynamic} bound to {@link java.lang.invoke.StringConcatFactory}, storing the literal
+ * fragments in the bootstrap recipe/constant arguments rather than as {@code LDC} instructions.
+ * The engine encrypts {@code LDC} and {@code ConstantValue} channels; a recipe cannot be rewritten
+ * to a decode call because {@code StringConcatFactory} interprets it at link time, so those
+ * fragments would ship in cleartext. They are counted and reported rather than silently left,
+ * so a build compiled that way is not believed fully string-encrypted. Compiling with
+ * {@code -XDstringConcat=inline} (or an older {@code -target}) emits {@code StringBuilder} the
+ * engine encrypts.
+ */
+ public int getConcatLiteralCount() {
+ return concatLiteralCount;
+ }
+
+ /**
+ * The number of {@code static final String} constants on pre-Java-8 interfaces that this transform
+ * left in plaintext. Such an interface cannot host a {@code } or the decoder (both need
+ * class-file version 52), so its own {@code ConstantValue} attribute cannot be moved to a decode
+ * call. Every read of the constant elsewhere was inlined by javac to an {@code LDC} and is
+ * encrypted there, so the value is hidden at each use; only the declaring interface's constant pool
+ * still carries it. Counted and reported rather than shipped silently.
+ */
+ public int getLegacyInterfaceConstantCount() {
+ return legacyInterfaceConstantCount;
+ }
+
+ /**
+ * The number of distinct string literals this transform would have encrypted but left in plaintext
+ * because their ciphertext could overflow the 65535-byte constant-pool limit (a valid ASCII literal
+ * longer than 21,845 characters can widen to a 3-byte-per-char modified-UTF-8 constant). A large
+ * embedded credential, JSON document or encoded blob therefore stays readable; counted and reported
+ * so an {@code strings:all} build is not believed to have encrypted everything.
+ */
+ public int getOversizedLiteralCount() {
+ return oversizedLiteralCount;
+ }
+
+ /**
+ * The number of {@code LDC ConstantDynamic} sites carrying a String among their bootstrap arguments
+ * that this transform did NOT encrypt. Java 11+ bytecode can materialize a constant through a
+ * {@code constant-dynamic} whose bootstrap arguments hold plaintext; those live neither in a direct
+ * {@code LDC "..."} nor in a field {@code ConstantValue}, and the condy is resolved at link time, so
+ * rewriting it to a decode call is unsafe. Counted and reported rather than shipped unremarked.
+ */
+ /**
+ * The number of {@code invokedynamic} sites -- other than the {@code StringConcatFactory} concat
+ * recipes counted by {@link #getConcatLiteralCount()} -- whose bootstrap arguments carry a plaintext
+ * String (directly or through a nested constant-dynamic). A custom {@code invokedynamic} emitted by a
+ * bytecode generator can hold a literal in its bootstrap arguments that no {@code LDC}/
+ * {@code ConstantValue} pass reaches, so it stays readable; reported so an {@code strings:all} build
+ * is not believed to have encrypted every string.
+ */
+ public int getIndyLiteralCount() {
+ return indyLiteralCount;
+ }
+
+ /**
+ * The number of distinct one- and two-character string literals the current mode would have
+ * encrypted but left plaintext because they are too short to be worth the decoder overhead. A
+ * two-character value is trivially brute-forced even when encrypted, so this is a disclosure note
+ * (the {@code strings:all} claim does not silently omit them), not a correctness risk.
+ */
+ public int getShortLiteralCount() {
+ return shortLiteralCount;
+ }
+
+ /**
+ * True when this class was shipped UNHARDENED because a frame merge could not be resolved past a
+ * missing intermediate supertype (see {@link FrameClassWriter#isHierarchyIncomplete}). Reported so the
+ * coverage summary discloses the class rather than silently counting it as hardened.
+ */
+ public boolean isHierarchyIncompleteSkipped() {
+ return hierarchyIncompleteSkipped;
+ }
+
+ public int getCondyLiteralCount() {
+ return condyLiteralCount;
+ }
+
+ /**
+ * The number of literals left plaintext because the class's {@code } is already so close to
+ * the 65,535-byte method limit that even the split path's helper calls would not fit. Extremely
+ * rare (a class whose static initializer is itself near the limit), but reported rather than
+ * aborting the build so a valid input class is never rejected.
+ */
+ public int getClinitFullLiteralCount() {
+ return clinitFullLiteralCount;
+ }
+
+ /**
+ * The number of literals left plaintext because encrypting them per access would have pushed the
+ * enclosing method past the 65,535-byte limit. Only the interface path decodes per access (a class's
+ * literals are hoisted to {@code }, which does not grow the method body), so this is rare;
+ * reported rather than aborting the build on a valid input class.
+ */
+ public int getMethodFullLiteralCount() {
+ return methodFullLiteralCount;
+ }
+
+ /**
+ * The number of distinct string values the current mode would encrypt that are stored in annotation
+ * element values or annotation defaults. javac keeps those in the annotation metadata, not as an
+ * {@code LDC} or a field {@code ConstantValue}, so no encryption channel reaches them; they stay
+ * readable. Counted and reported so an {@code strings:all} build is not believed to have encrypted
+ * every string. (Codename One has no runtime reflection to read an annotation value back, so this is
+ * a disclosure note, not a correctness risk.)
+ */
+ public int getAnnotationLiteralCount() {
+ return annotationLiteralCount;
+ }
+
+
+ /** Encrypts {@code classBytes}, returning the transformed bytes (or the input if nothing changed). */
+ public byte[] transform(byte[] classBytes) {
+ ClassNode cn = new ClassNode();
+ ClassReader reader = new ClassReader(classBytes);
+ reader.accept(cn, ClassReader.SKIP_FRAMES);
+ // The input's current constant-pool item count; encryption must not grow the pool past 65535.
+ poolBaseItems = reader.getItemCount();
+ poolItemsRemaining = MethodSize.SAFE_POOL_ITEMS - poolBaseItems - DECODER_POOL_OVERHEAD;
+
+ boolean isInterface = (cn.access & Opcodes.ACC_INTERFACE) != 0;
+ // The decoder is a concrete static method, and (for interface constants) it is invoked from
+ // . Static/private methods and in an interface are only valid from class-file
+ // version 52 (Java 8). A pre-Java-8 interface therefore cannot host the decoder, and such an
+ // interface has no default/static method bodies to hold LDC literals anyway, so skip it whole
+ // rather than emit a class that fails verification.
+ if (isInterface && (cn.version & 0xFFFF) < Opcodes.V1_8) {
+ // A pre-Java-8 interface cannot host a /decoder, so its own static-final String
+ // ConstantValue attributes cannot be moved to a decode call and stay plaintext. Count the
+ // ones we would otherwise have encrypted so the engine reports the exclusion instead of
+ // silently shipping them; javac already inlined (and this pass encrypts) every read site.
+ if (cn.fields != null) {
+ for (FieldNode f : cn.fields) {
+ if ((f.access & Opcodes.ACC_STATIC) != 0 && (f.access & Opcodes.ACC_FINAL) != 0
+ && f.value instanceof String && shouldEncryptLiteral((String) f.value)) {
+ legacyInterfaceConstantCount++;
+ // Exclude it jar-wide: this ConstantValue stays plaintext here, so an equal LDC in
+ // another class must NOT be encrypted+interned or a GETSTATIC read of this field
+ // would compare != to that interned copy on ParparVM (a broken literal ==).
+ newlyExcluded.add((String) f.value);
+ }
+ }
+ }
+ return classBytes;
+ }
+
+ // Pick a decoder name that does not collide with an existing member, so a class is NEVER
+ // skipped for a name clash. Skipping would leave that class's literals in plaintext while an
+ // equal literal in another class was encrypted+interned; on ParparVM, whose intern pool does
+ // not contain the compile-time literals, the two would then fail a valid literal '==' compare.
+ // Never skipping keeps encryption applied by-value across the whole jar, so all occurrences of
+ // a value are decoded through the shared intern pool and stay reference-equal.
+ String decoderName = resolveDecoderName(cn);
+
+ // Count invokedynamic string-concatenation literals we cannot encrypt (see
+ // getConcatLiteralCount). Done before the mutating passes so it is independent of them; the
+ // engine turns a non-zero total into a build warning so plaintext concat fragments are never
+ // silently shipped.
+ concatLiteralCount += countConcatLiterals(cn);
+ condyLiteralCount += countCondyLiterals(cn);
+ indyLiteralCount += countIndyLiterals(cn);
+ annotationLiteralCount += countAnnotationStrings(cn);
+ shortLiteralCount += countShortLiterals(cn);
+ // Count the distinct literals that would be encrypted but are too large to (their ciphertext
+ // could overflow the constant pool), so the engine can report the exclusion rather than let an
+ // strings:all build claim it encrypted everything.
+ oversizedLiteralCount += countOversizedLiterals(cn);
+
+ int base = keyBase(cn.name);
+ boolean changed = false;
+
+ // The decoder method and its references are added whenever anything is encrypted, at a fixed
+ // constant-pool cost. If the class's pool is already so full that even that overhead would not
+ // fit, nothing can be encrypted here without ClassTooLargeException. Skip the class and record
+ // the values it would have selected as jar-wide exclusions, so those values are left plaintext in
+ // every OTHER class too -- otherwise a value encrypted+interned elsewhere would compare != to the
+ // plaintext copy here on ParparVM.
+ if (poolItemsRemaining < 0) {
+ collectSelectedValues(cn, newlyExcluded);
+ return classBytes;
+ }
+
+ // Channel 1: LDC string literals in method bodies. In "all" mode every literal is
+ // encrypted; in "constants" mode only literals whose value was declared as a
+ // static-final String constant somewhere in the jar -- which is exactly the set javac
+ // inlined at these read sites -- so ordinary incidental literals are left alone.
+ //
+ // For a normal class each distinct literal is hoisted to a synthetic static field decoded
+ // ONCE in , and its LDC sites become a GETSTATIC of that field, so a literal in a hot
+ // loop is not re-decoded (and re-interned -- an O(n) scan on ParparVM) on every iteration. An
+ // interface can't host that (its fields are public), so there the literal is decoded per
+ // access; interface method bodies are rare and not hot loops.
+ if (cn.methods != null) {
+ if (isInterface) {
+ changed |= encryptAllMethodsPerAccess(cn, base, decoderName, true);
+ } else {
+ changed |= hoistMethodLiterals(cn, base, decoderName);
+ }
+ }
+
+ // Channel 2: static final String ConstantValue attributes (both modes), including
+ // interfaces. A Java 8 interface may carry a for non-constant field initialization,
+ // so an interface constant's plaintext can be moved to a decoder call there just as a class
+ // field's is -- otherwise "String TOKEN = \"secret\"" would still leak the plaintext.
+ changed |= encryptStaticFinalStrings(cn, base, isInterface, decoderName);
+
+ if (!changed) {
+ return classBytes;
+ }
+
+ addDecoder(cn, base, isInterface, decoderName);
+
+ FrameClassWriter cw = new FrameClassWriter(ClassWriter.COMPUTE_MAXS | ClassWriter.COMPUTE_FRAMES, hierarchy);
+ cn.accept(cw);
+ if (cw.isHierarchyIncomplete()) {
+ // COMPUTE_FRAMES had to guess java/lang/Object for a merge whose real common base sits beyond a
+ // supertype absent from the supplied jars (e.g. A extends PlatformA, B extends PlatformB, both
+ // extend an absent Base). The recomputed frame may be too weak and fail on-device verification.
+ // Ship this class UNHARDENED -- its original javac-computed frames already encode the precise
+ // type -- rather than a possibly-invalid recomputed one. Its literals then stay plaintext, so
+ // exclude ALL of them jar-wide: a value encrypted+interned in another class must not compare !=
+ // to this class's plaintext copy on ParparVM's deduplicated pool.
+ collectAllLiterals(classBytes, newlyExcluded);
+ // No literal is actually encrypted for a discarded class; clear the applied count so the engine
+ // does not count these toward "some transform ran" and stamp cn1.hardened=true for output whose
+ // bytes never changed. The literals are disclosed via the hierarchy-incomplete skip instead.
+ encryptedCount = 0;
+ hierarchyIncompleteSkipped = true;
+ return classBytes;
+ }
+ return cw.toByteArray();
+ }
+
+ /**
+ * Encrypts every method-body literal in {@code cn} PER ACCESS (an {@code LDC} ciphertext + a decoder
+ * {@code INVOKESTATIC}), rather than hoisting distinct values to fields. Used for interfaces (whose
+ * fields are public) and as the fallback when hoisting a class would overflow the constant pool or
+ * its {@code } is already full: per-access adds no per-value field, so it does not grow the
+ * pool, and it keeps EVERY occurrence of a value encrypted and interned -- preserving a valid
+ * cross-class literal {@code ==} on ParparVM instead of leaving some copies plaintext.
+ */
+ private boolean encryptAllMethodsPerAccess(ClassNode cn, int base, String decoderName,
+ boolean isInterface) {
+ boolean changed = false;
+ if (cn.methods != null) {
+ // Distinct values already charged against the pool budget in this pass. ASM folds equal
+ // ciphertext strings to one constant, so a value re-encountered in another method adds no
+ // further pool entry and must not be charged twice.
+ java.util.Set pooledThisPass = new java.util.HashSet();
+ for (MethodNode mn : cn.methods) {
+ if (mn.instructions == null || decoderName.equals(mn.name)) {
+ continue;
+ }
+ changed |= encryptMethodLiterals(cn, mn, base, isInterface, decoderName, pooledThisPass);
+ }
+ }
+ return changed;
+ }
+
+ private boolean encryptMethodLiterals(ClassNode cn, MethodNode mn, int base, boolean isInterface,
+ String decoderName, java.util.Set pooledThisPass) {
+ boolean changed = false;
+ // Each rewrite inserts an INVOKESTATIC, growing the method. A method already near the limit
+ // cannot take unbounded rewrites, so track the running size and stop (leaving the remaining
+ // literals plaintext, reported) before the method would overflow, rather than aborting the build.
+ int currentBytes = MethodSize.estimateBytes(mn.instructions);
+ AbstractInsnNode insn = mn.instructions.getFirst();
+ while (insn != null) {
+ AbstractInsnNode next = insn.getNext();
+ if (insn instanceof LdcInsnNode) {
+ LdcInsnNode ldc = (LdcInsnNode) insn;
+ if (ldc.cst instanceof String && shouldEncryptLiteral((String) ldc.cst)) {
+ String plain = (String) ldc.cst;
+ if (currentBytes + DECODER_CALL_BYTES > MethodSize.SAFE_LIMIT) {
+ // This method cannot grow to hold the decode call. Record the value as a jar-wide
+ // exclusion so the engine leaves it plaintext in every class -- encrypting it
+ // elsewhere would break a valid literal == against this plaintext copy.
+ methodFullLiteralCount++;
+ newlyExcluded.add(plain);
+ } else if (!pooledThisPass.contains(plain)
+ && poolItemsRemaining < POOL_ITEMS_PER_ACCESS) {
+ // The ciphertext for a not-yet-charged value would push the class constant pool
+ // past the 65535-entry limit (per-access is not pool-neutral when the plaintext is
+ // retained elsewhere). Leaving some occurrences encrypted and others plaintext
+ // would break a valid literal ==, so exclude the value jar-wide (plaintext in
+ // every class) and let the engine's second pass re-run with it excluded, rather
+ // than let ClassWriter.toByteArray() throw ClassTooLargeException.
+ newlyExcluded.add(plain);
+ } else {
+ // Charge the pool budget the first time a distinct value is encrypted this pass;
+ // repeats of the same value reuse the folded ciphertext constant for free.
+ if (pooledThisPass.add(plain)) {
+ poolItemsRemaining -= POOL_ITEMS_PER_ACCESS;
+ }
+ // shouldEncrypt already rejected any value whose ciphertext could overflow the
+ // constant pool, using a class-independent bound, so the encode result fits.
+ ldc.cst = encode(plain, base);
+ // The itf flag must be true when the decoder lives in an interface, or the JVM
+ // writes a Methodref instead of an InterfaceMethodref and throws
+ // IncompatibleClassChangeError at run time.
+ mn.instructions.insert(ldc, new MethodInsnNode(
+ Opcodes.INVOKESTATIC, cn.name, decoderName, DECODER_DESC, isInterface));
+ currentBytes += DECODER_CALL_BYTES;
+ encryptedCount++;
+ changed = true;
+ }
+ }
+ }
+ insn = next;
+ }
+ return changed;
+ }
+
+ /**
+ * Counts the {@code invokedynamic} string-concatenation sites in {@code cn} that carry plaintext
+ * the engine cannot reach. A {@code makeConcatWithConstants} recipe embeds the literal fragments of
+ * {@code "a" + b} directly (any character other than the U+0001 argument marker and the
+ * U+0002 constant marker), and additional constant fragments arrive as String bootstrap
+ * arguments after the recipe. Either form leaves cleartext in the constant pool that no
+ * {@code LDC}/{@code ConstantValue} pass touches. Counts the site once when it bears any literal
+ * text, so the reported number tracks concat sites rather than characters.
+ */
+ private static int countConcatLiterals(ClassNode cn) {
+ if (cn.methods == null) {
+ return 0;
+ }
+ int count = 0;
+ for (MethodNode mn : cn.methods) {
+ if (mn.instructions == null) {
+ continue;
+ }
+ for (AbstractInsnNode insn = mn.instructions.getFirst(); insn != null; insn = insn.getNext()) {
+ if (!(insn instanceof InvokeDynamicInsnNode)) {
+ continue;
+ }
+ InvokeDynamicInsnNode indy = (InvokeDynamicInsnNode) insn;
+ Handle bsm = indy.bsm;
+ if (bsm == null
+ || !"java/lang/invoke/StringConcatFactory".equals(bsm.getOwner())
+ || !"makeConcatWithConstants".equals(bsm.getName())) {
+ continue;
+ }
+ if (concatSiteHasLiteral(indy.bsmArgs)) {
+ count++;
+ }
+ }
+ }
+ return count;
+ }
+
+ /**
+ * True when a {@code makeConcatWithConstants} site carries any plaintext: a recipe (the first
+ * bootstrap argument) with a character that is neither the U+0001 argument marker nor the
+ * U+0002 constant marker, or any String constant among the later bootstrap arguments.
+ */
+ private static boolean concatSiteHasLiteral(Object[] bsmArgs) {
+ if (bsmArgs == null || bsmArgs.length == 0) {
+ return false;
+ }
+ if (bsmArgs[0] instanceof String) {
+ String recipe = (String) bsmArgs[0];
+ for (int i = 0; i < recipe.length(); i++) {
+ char c = recipe.charAt(i);
+ if (c != TAG_ARG && c != TAG_CONST) {
+ return true;
+ }
+ }
+ }
+ for (int i = 1; i < bsmArgs.length; i++) {
+ if (bsmArgs[i] instanceof String) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Counts the {@code LDC ConstantDynamic} sites in {@code cn} whose bootstrap arguments include a
+ * String -- Java 11+ can carry plaintext through a constant-dynamic (e.g. an enum switch map or an
+ * explicit-condy compiler), which no {@code LDC}/{@code ConstantValue} pass reaches. Counts the site
+ * once when it bears any String argument.
+ */
+ private static int countCondyLiterals(ClassNode cn) {
+ if (cn.methods == null) {
+ return 0;
+ }
+ int count = 0;
+ for (MethodNode mn : cn.methods) {
+ if (mn.instructions == null) {
+ continue;
+ }
+ for (AbstractInsnNode insn = mn.instructions.getFirst(); insn != null; insn = insn.getNext()) {
+ if (insn instanceof LdcInsnNode && ((LdcInsnNode) insn).cst instanceof ConstantDynamic) {
+ if (condyHasStringArgument((ConstantDynamic) ((LdcInsnNode) insn).cst)) {
+ count++;
+ }
+ }
+ }
+ }
+ return count;
+ }
+
+ /**
+ * Counts the {@code invokedynamic} sites in {@code cn} that carry a plaintext String in their
+ * bootstrap arguments and are NOT the {@code StringConcatFactory.makeConcatWithConstants} concat
+ * recipes already tallied by {@link #countConcatLiterals(ClassNode)} (skipped here to avoid
+ * double-counting). A custom {@code invokedynamic} from a bytecode generator can hold a literal --
+ * directly or in a nested constant-dynamic argument -- that no {@code LDC}/{@code ConstantValue} pass
+ * reaches. Counts the site once when any bootstrap argument bears a String.
+ */
+ private static int countIndyLiterals(ClassNode cn) {
+ if (cn.methods == null) {
+ return 0;
+ }
+ int count = 0;
+ for (MethodNode mn : cn.methods) {
+ if (mn.instructions == null) {
+ continue;
+ }
+ for (AbstractInsnNode insn = mn.instructions.getFirst(); insn != null; insn = insn.getNext()) {
+ if (!(insn instanceof InvokeDynamicInsnNode)) {
+ continue;
+ }
+ InvokeDynamicInsnNode indy = (InvokeDynamicInsnNode) insn;
+ Handle bsm = indy.bsm;
+ if (bsm != null
+ && "java/lang/invoke/StringConcatFactory".equals(bsm.getOwner())
+ && "makeConcatWithConstants".equals(bsm.getName())) {
+ continue;
+ }
+ if (indyBsmArgsHaveString(indy.bsmArgs)) {
+ count++;
+ }
+ }
+ }
+ return count;
+ }
+
+ /** True when any bootstrap argument is a String, or a constant-dynamic that (nested) carries one. */
+ private static boolean indyBsmArgsHaveString(Object[] bsmArgs) {
+ if (bsmArgs == null) {
+ return false;
+ }
+ for (Object arg : bsmArgs) {
+ if (arg instanceof String) {
+ return true;
+ }
+ if (arg instanceof ConstantDynamic && condyHasStringArgument((ConstantDynamic) arg)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Counts the distinct one- and two-character string literals in {@code cn} that the current mode
+ * would encrypt (every {@code LDC} in "all" mode; a declared constant in "constants" mode) but
+ * {@link #shouldEncrypt(String)} leaves plaintext because they are too short to be worth the decoder
+ * overhead. Empty strings carry no information and are not counted; reported so the coverage claim
+ * discloses the short-literal exclusion rather than silently omitting it.
+ */
+ private int countShortLiterals(ClassNode cn) {
+ java.util.Set found = new java.util.HashSet();
+ if (cn.methods != null) {
+ for (MethodNode mn : cn.methods) {
+ if (mn.instructions == null) {
+ continue;
+ }
+ for (AbstractInsnNode insn = mn.instructions.getFirst(); insn != null; insn = insn.getNext()) {
+ if (insn instanceof LdcInsnNode && ((LdcInsnNode) insn).cst instanceof String) {
+ String v = (String) ((LdcInsnNode) insn).cst;
+ if (v.length() >= 1 && v.length() <= 2 && wouldSelectButForLength(v)) {
+ found.add(v);
+ }
+ }
+ }
+ }
+ }
+ // The static-final ConstantValue channel skips short values for the same reason: encryptStaticFinalStrings
+ // gates on shouldEncryptLiteral -> shouldEncrypt, which rejects length <= 2, so a short static-final
+ // String is left plaintext in its ConstantValue slot and leaks into ParparVM's C pool uncounted. Include
+ // it (distinct by value, dedup'd with the LDC channel above) exactly as countOversizedLiterals does, so
+ // an strings:all build still discloses the exclusion rather than silently advertising full coverage.
+ if (cn.fields != null) {
+ for (FieldNode fn : cn.fields) {
+ if ((fn.access & Opcodes.ACC_STATIC) != 0 && fn.value instanceof String) {
+ String v = (String) fn.value;
+ if (v.length() >= 1 && v.length() <= 2 && wouldSelectButForLength(v)) {
+ found.add(v);
+ }
+ }
+ }
+ }
+ return found.size();
+ }
+
+ /** True when a value would be an encryption candidate in the current mode if it were long enough. */
+ private boolean wouldSelectButForLength(String v) {
+ return encryptAllStrings || (constantValues != null && constantValues.contains(v));
+ }
+
+ /** True when a constant-dynamic carries a String among its bootstrap arguments, nested ones too. */
+ private static boolean condyHasStringArgument(ConstantDynamic condy) {
+ return condyHasStringArgument(condy, 0);
+ }
+
+ private static boolean condyHasStringArgument(ConstantDynamic condy, int depth) {
+ // A constant-dynamic bootstrap argument can itself be a ConstantDynamic whose arguments hold the
+ // plaintext, so recurse. The depth guard is a backstop against a pathological/cyclic pool.
+ if (depth > 16) {
+ return false;
+ }
+ for (int i = 0, n = condy.getBootstrapMethodArgumentCount(); i < n; i++) {
+ Object arg = condy.getBootstrapMethodArgument(i);
+ if (arg instanceof String) {
+ return true;
+ }
+ if (arg instanceof ConstantDynamic
+ && condyHasStringArgument((ConstantDynamic) arg, depth + 1)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Counts the distinct string values the current mode would encrypt that live in annotation element
+ * values or defaults (see {@link #getAnnotationLiteralCount()}). Walks EVERY annotation collection
+ * ASM exposes -- ordinary and type-use annotations on the class, its fields, methods, method
+ * parameters and local variables, plus record-component annotations and method annotation defaults
+ * -- recursing into nested annotations and array values; skips enum references ({@code String[]}) and
+ * {@code Type}, which are not string literals.
+ */
+ private int countAnnotationStrings(ClassNode cn) {
+ java.util.Set found = new java.util.HashSet();
+ collectAnnotations(cn.visibleAnnotations, found);
+ collectAnnotations(cn.invisibleAnnotations, found);
+ collectAnnotations(cn.visibleTypeAnnotations, found);
+ collectAnnotations(cn.invisibleTypeAnnotations, found);
+ if (cn.recordComponents != null) {
+ for (org.objectweb.asm.tree.RecordComponentNode rc : cn.recordComponents) {
+ collectAnnotations(rc.visibleAnnotations, found);
+ collectAnnotations(rc.invisibleAnnotations, found);
+ collectAnnotations(rc.visibleTypeAnnotations, found);
+ collectAnnotations(rc.invisibleTypeAnnotations, found);
+ }
+ }
+ if (cn.fields != null) {
+ for (FieldNode f : cn.fields) {
+ collectAnnotations(f.visibleAnnotations, found);
+ collectAnnotations(f.invisibleAnnotations, found);
+ collectAnnotations(f.visibleTypeAnnotations, found);
+ collectAnnotations(f.invisibleTypeAnnotations, found);
+ }
+ }
+ if (cn.methods != null) {
+ for (MethodNode m : cn.methods) {
+ collectAnnotations(m.visibleAnnotations, found);
+ collectAnnotations(m.invisibleAnnotations, found);
+ collectAnnotations(m.visibleTypeAnnotations, found);
+ collectAnnotations(m.invisibleTypeAnnotations, found);
+ collectAnnotations(m.visibleLocalVariableAnnotations, found);
+ collectAnnotations(m.invisibleLocalVariableAnnotations, found);
+ collectParameterAnnotations(m.visibleParameterAnnotations, found);
+ collectParameterAnnotations(m.invisibleParameterAnnotations, found);
+ collectAnnotationValue(m.annotationDefault, found);
+ }
+ }
+ return found.size();
+ }
+
+ private void collectAnnotations(java.util.List extends AnnotationNode> list,
+ java.util.Set out) {
+ if (list == null) {
+ return;
+ }
+ for (AnnotationNode an : list) {
+ collectAnnotationNode(an, out);
+ }
+ }
+
+ private void collectParameterAnnotations(java.util.List[] params,
+ java.util.Set out) {
+ if (params == null) {
+ return;
+ }
+ for (java.util.List list : params) {
+ collectAnnotations(list, out);
+ }
+ }
+
+ private void collectAnnotationNode(AnnotationNode an, java.util.Set out) {
+ if (an == null || an.values == null) {
+ return;
+ }
+ // values is a flat [name, value, name, value, ...] list; only the values can hold strings.
+ for (int i = 1; i < an.values.size(); i += 2) {
+ collectAnnotationValue(an.values.get(i), out);
+ }
+ }
+
+ private void collectAnnotationValue(Object value, java.util.Set out) {
+ if (value instanceof String) {
+ if (modeSelectsLiteral((String) value)) {
+ out.add((String) value);
+ }
+ } else if (value instanceof AnnotationNode) {
+ collectAnnotationNode((AnnotationNode) value, out);
+ } else if (value instanceof java.util.List) {
+ for (Object e : (java.util.List>) value) {
+ collectAnnotationValue(e, out);
+ }
+ }
+ // A String[] is an enum reference {descriptor, constant} and a Type is a class literal -- neither
+ // is a user string literal, so both are left uncounted.
+ }
+
+ /**
+ * Counts the distinct literals in {@code cn} that this transform would encrypt but skips because
+ * their ciphertext could overflow the constant pool (see {@link #getOversizedLiteralCount()}).
+ * Covers both channels: method-body {@code LDC}s selected by the current mode, and
+ * {@code static final String} {@code ConstantValue}s (encrypted regardless of mode). Distinct by
+ * value within the class, mirroring how encryption dedups.
+ */
+ private int countOversizedLiterals(ClassNode cn) {
+ java.util.Set skipped = new java.util.HashSet();
+ if (cn.methods != null) {
+ for (MethodNode mn : cn.methods) {
+ if (mn.instructions == null) {
+ continue;
+ }
+ for (AbstractInsnNode insn = mn.instructions.getFirst(); insn != null; insn = insn.getNext()) {
+ if (insn instanceof LdcInsnNode && ((LdcInsnNode) insn).cst instanceof String) {
+ String v = (String) ((LdcInsnNode) insn).cst;
+ if (isOversized(v) && modeSelectsLiteral(v)) {
+ skipped.add(v);
+ }
+ }
+ }
+ }
+ }
+ if (cn.fields != null) {
+ for (FieldNode fn : cn.fields) {
+ if ((fn.access & Opcodes.ACC_STATIC) != 0 && fn.value instanceof String) {
+ String v = (String) fn.value;
+ // encryptStaticFinalStrings uses shouldEncrypt (mode-independent), so any oversized
+ // static-final String would be skipped.
+ if (v.length() > 2 && isOversized(v)) {
+ skipped.add(v);
+ }
+ }
+ }
+ }
+ return skipped.size();
+ }
+
+ /** Collects the distinct values the mode would encrypt (method LDCs + static-final constants). */
+ private void collectSelectedValues(ClassNode cn, java.util.Set out) {
+ if (cn.methods != null) {
+ for (MethodNode mn : cn.methods) {
+ if (mn.instructions == null) {
+ continue;
+ }
+ for (AbstractInsnNode insn = mn.instructions.getFirst(); insn != null; insn = insn.getNext()) {
+ if (insn instanceof LdcInsnNode && ((LdcInsnNode) insn).cst instanceof String
+ && shouldEncryptLiteral((String) ((LdcInsnNode) insn).cst)) {
+ out.add((String) ((LdcInsnNode) insn).cst);
+ }
+ }
+ }
+ }
+ if (cn.fields != null) {
+ for (FieldNode fn : cn.fields) {
+ if ((fn.access & Opcodes.ACC_STATIC) != 0 && fn.value instanceof String
+ && shouldEncryptLiteral((String) fn.value)) {
+ out.add((String) fn.value);
+ }
+ }
+ }
+ }
+
+ /** True when {@code s}'s worst-case ciphertext would overflow the 65535-byte constant-pool limit. */
+ private static boolean isOversized(String s) {
+ return s != null && (long) s.length() * 3 > 65535;
+ }
+
+ /** True when the current mode would select {@code s} for method-literal encryption (size aside). */
+ private boolean modeSelectsLiteral(String s) {
+ if (s == null || s.length() <= 2) {
+ return false;
+ }
+ if (jarExcluded != null && jarExcluded.contains(s)) {
+ return false;
+ }
+ return encryptAllStrings || (constantValues != null && constantValues.contains(s));
+ }
+
+ /**
+ * Hoists each distinct encryptable method-body literal in {@code cn} to a synthetic static field
+ * decoded once in {@code }, and rewrites its LDC sites to a GETSTATIC of that field. So a
+ * literal read in a loop pays the decode + intern cost once at class load instead of on every
+ * access. Fields are private and synthetic; the decoded value is interned, so all sites of one
+ * value -- and equal values in other classes -- stay reference-equal.
+ */
+ private boolean hoistMethodLiterals(ClassNode cn, int base, String decoderName) {
+ // 1. Collect the distinct values, in first-seen order, each mapped to a field name that does
+ // NOT collide with an existing member (an input class may already declare a zqL$N field;
+ // on Android the engine doesn't rename first, so this is reachable and a duplicate field
+ // would make the class fail to load).
+ java.util.Set