(flags.size());
101 | for (final Object flag : flags) {
102 | options.add((VMOption) getVMOptionMethod.invoke(flag));
103 | }
104 | } catch (Exception e) {
105 |
106 | Class> inaccessibleException = null;
107 | try {
108 | inaccessibleException = Class.forName("java.lang.reflect.InaccessibleObjectException");
109 | } catch (ClassNotFoundException e1) {
110 | }
111 |
112 | if (inaccessibleException != null && e.getClass().equals(inaccessibleException)) {
113 | LOGGER.log(Level.SEVERE, "This JVM does not open package jdk.management/com.sun.management.internal to this module. To include all flags, run with --add-opens jdk.management/com.sun.management.internal=ALL-UNNAMED");
114 | }
115 | }
116 | return options;
117 | }
118 |
119 | public static class JVMFlag {
120 | private String name;
121 | private String value;
122 | private String origin;
123 | private boolean writable;
124 |
125 | private final String _toString;
126 |
127 | public JVMFlag(String name, String value, String origin, boolean writable) {
128 | this.name = name;
129 | this.value = value;
130 | this.origin = origin;
131 | this.writable = writable;
132 |
133 | _toString = String.format("%s = %s (%s, %s)", name, value, origin, writable ? "read-write" : "read-only");
134 | }
135 |
136 | public String getName() {
137 | return name;
138 | }
139 |
140 | public String getValue() {
141 | return value;
142 | }
143 |
144 | public String getOrigin() {
145 | return origin;
146 | }
147 |
148 | public boolean isWritable() {
149 | return writable;
150 | }
151 |
152 | @Override
153 | public String toString() {
154 | return _toString;
155 | }
156 | }
157 |
158 | public String getVMOption(String vmOptionName) {
159 | return jvmFlags.stream().filter(flag -> flag.getName().equals(vmOptionName)).findFirst().map(JVMFlag::getValue)
160 | .orElse(null);
161 | }
162 |
163 | }
--------------------------------------------------------------------------------
/docs/assets/css/style.css:
--------------------------------------------------------------------------------
1 | /* Dark theme palette */
2 | :root { --c-bg:#0c1116; --c-bg-alt:#131b25; --c-panel:#162231; --c-accent:#ff7a18; --c-accent-alt:#ffb347; --c-fg:#e6edf3; --c-fg-dim:#9fb3c8; --radius:14px; }
3 | /* Global scroll offset for in-page anchors (adjust if header height changes) */
4 | html { scroll-padding-top:80px; }
5 | h1[id],h2[id],h3[id],h4[id],h5[id],h6[id] { scroll-margin-top:80px; }
6 | body { margin:0; font-family:'Inter',system-ui,-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif; background:var(--c-bg); color:var(--c-fg); -webkit-font-smoothing:antialiased; }
7 | a { color:#63b3ff; text-decoration:none; }
8 | a:hover { color:#fff; }
9 | .wrap { width:100%; max-width:1180px; margin:0 auto; padding:0 1.4rem; }
10 | .site-header { position:sticky; top:0; backdrop-filter: blur(12px); background:rgba(12,17,22,.85); border-bottom:1px solid #1d2a36; z-index:40; }
11 | .nav-bar { display:flex; align-items:center; justify-content:space-between; min-height:60px; }
12 | .logo { font-weight:700; font-size:1.05rem; letter-spacing:.5px; background:linear-gradient(120deg,var(--c-accent),var(--c-accent-alt)); -webkit-background-clip:text; background-clip:text; color:transparent; }
13 | .primary-nav a { margin-left:1.1rem; font-size:.9rem; color:var(--c-fg-dim); }
14 | .primary-nav a:hover { color:var(--c-fg); }
15 | #themeToggle { margin-left:1.1rem; background:var(--c-panel); color:var(--c-fg-dim); border:1px solid #223344; padding:.45rem .65rem; border-radius:8px; cursor:pointer; font-size:.85rem; }
16 | #themeToggle:hover { color:var(--c-fg); border-color:#31475c; }
17 |
18 | /* Hero */
19 | .hero { margin-top:2.5rem; padding:3.2rem 2rem 2.7rem; background:radial-gradient(circle at 20% 20%,#1b2733,#0c1116 70%); border:1px solid #1c2833; border-radius:var(--radius); position:relative; overflow:hidden; }
20 | .hero:before,.hero:after { content:""; position:absolute; width:520px; height:520px; border-radius:50%; filter:blur(90px); opacity:.35; mix-blend-mode:screen; pointer-events:none; }
21 | .hero:before { background:#ff7a18; top:-160px; left:-160px; }
22 | .hero:after { background:#5c6fff; bottom:-200px; right:-140px; }
23 | .hero h1 { margin:0 0 1rem; font-size: clamp(2.3rem,5vw,3.2rem); line-height:1.05; }
24 | .hero p.tagline { font-size:1.15rem; max-width:880px; line-height:1.35; }
25 | .cta-row { margin-top:1.6rem; display:flex; flex-wrap:wrap; gap:.85rem; }
26 | .btn { --_bg:var(--c-panel); --_border:#253648; display:inline-block; padding:.85rem 1.15rem; border-radius:10px; border:1px solid var(--_border); background:var(--_bg); color:var(--c-fg-dim); font-weight:500; font-size:.9rem; letter-spacing:.2px; transition:.2s; }
27 | .btn:hover { color:var(--c-fg); border-color:#345068; }
28 | .btn.accent { background:linear-gradient(110deg,var(--c-accent),var(--c-accent-alt)); color:#111; border:none; font-weight:600; }
29 | .btn.accent:hover { filter:brightness(1.05); }
30 |
31 | /* Feature grid */
32 | .feature-grid { display:grid; gap:1.35rem; grid-template-columns:repeat(auto-fit,minmax(250px,1fr)); margin:2.4rem 0 0; }
33 | .feature { background:var(--c-panel); padding:1.05rem 1rem 1.25rem; border:1px solid #1f2d3a; border-radius: var(--radius); position:relative; overflow:hidden; }
34 | .feature h3 { margin:.2rem 0 .55rem; font-size:1.05rem; letter-spacing:.5px; }
35 | .feature p { margin:0; font-size:.85rem; line-height:1.35; color:var(--c-fg-dim); }
36 | .feature:before { content:""; position:absolute; inset:0; background:linear-gradient(140deg,rgba(255,122,24,.08),rgba(92,111,255,.08)); opacity:0; transition:.3s; }
37 | .feature:hover:before { opacity:1; }
38 |
39 | /* Code & panels */
40 | pre { background:#111a22; color:#e9eef2; padding:.9rem 1rem; border:1px solid #1f2d3a; border-radius:12px; font-size:.8rem; overflow:auto; line-height:1.35; }
41 | code { font-family:Menlo,monospace; background:#17222c; padding:.2rem .45rem; border-radius:6px; font-size:.8rem; }
42 |
43 | .diagram { background:var(--c-panel); border:1px solid #1f2d3a; padding:1rem 1.2rem; border-radius:12px; font-family:Menlo,monospace; font-size:.75rem; line-height:1.25; margin-top:1.25rem; white-space:pre; overflow:auto; }
44 |
45 | table { width:100%; border-collapse:collapse; margin-top:.5rem; }
46 | th,td { padding:.55rem .65rem; border-bottom:1px solid #1d2a36; font-size:.75rem; }
47 | th { text-align:left; text-transform:uppercase; letter-spacing:.5px; font-weight:600; color:var(--c-fg-dim); }
48 |
49 | .two-col { display:grid; gap:1.6rem; grid-template-columns:repeat(auto-fit,minmax(340px,1fr)); margin-top:1.6rem; }
50 | .gallery { display:grid; gap:1rem; grid-template-columns:repeat(auto-fit,minmax(260px,1fr)); margin:2rem 0 1rem; }
51 | .gallery figure { margin:0; background:var(--c-panel); border:1px solid #1f2d3a; border-radius:12px; padding:.6rem .6rem .9rem; position:relative; overflow:hidden; }
52 | .gallery img { width:100%; height:160px; object-fit:cover; border-radius:8px; cursor:pointer; transition:.25s; filter:saturate(.9); }
53 | .gallery img:hover { transform:scale(1.03); filter:saturate(1.05); }
54 | .gallery figcaption { margin:.55rem 0 0; font-size:.7rem; letter-spacing:.5px; text-transform:uppercase; color:var(--c-fg-dim); }
55 | .lightbox { position:fixed; inset:0; background:rgba(0,0,0,.82); display:flex; flex-direction:column; align-items:center; justify-content:center; padding:2rem 1rem 2.5rem; backdrop-filter:blur(4px); z-index:120; }
56 | .lightbox img { max-width: min(94vw,1200px); max-height:70vh; border:1px solid #222e3a; border-radius:14px; box-shadow:0 10px 40px -8px rgba(0,0,0,.65); }
57 | .lightbox .close { position:absolute; top:1.2rem; right:1.4rem; background:#18222d; color:#fff; border:1px solid #2a3947; width:44px; height:44px; font-size:1.4rem; line-height:1; border-radius:50%; cursor:pointer; }
58 | .lightbox .close:hover { background:#223344; }
59 | .lightbox .caption { margin-top:1rem; font-size:.85rem; color:#b9c8d6; max-width: min(92vw,1100px); text-align:center; }
60 |
61 | .site-footer { margin-top:4rem; padding:2.2rem 0 3rem; background:#0a0f13; border-top:1px solid #19242e; }
62 | .site-footer p { margin:.35rem 0; }
63 | .small { font-size:.75rem; color:var(--c-fg-dim); }
64 |
65 | /* Light mode */
66 | body.theme-light { --c-bg:#ffffff; --c-bg-alt:#f5f7f9; --c-panel:#ffffff; --c-fg:#222; --c-fg-dim:#556270; }
67 | body.theme-light .site-header { background:rgba(255,255,255,.85); border-color:#e1e6eb; }
68 | body.theme-light .feature { border-color:#e3e8ee; }
69 | body.theme-light pre { background:#f3f6f9; border-color:#e3e8ee; color:#222; }
70 | body.theme-light code { background:#ebf0f4; }
71 | body.theme-light .diagram { background:#f3f6f9; border-color:#d9e1e8; }
72 | body.theme-light .site-footer { background:#f5f7f9; border-color:#e1e6eb; }
73 | body.theme-light .gallery figure { border-color:#dfe6ec; background:#ffffff; }
74 | body.theme-light .gallery figcaption { color:#5b6774; }
75 |
76 | @media (max-width:780px){ .hero { padding:2.4rem 1.3rem 2.2rem; } }
77 |
--------------------------------------------------------------------------------
/agent/src/main/java/io/github/brunoborges/jlib/agent/JarInventoryReport.java:
--------------------------------------------------------------------------------
1 | package io.github.brunoborges.jlib.agent;
2 |
3 | import java.io.PrintStream;
4 | import java.util.ArrayList;
5 | import java.util.Collection;
6 | import java.util.List;
7 |
8 | import io.github.brunoborges.jlib.common.JarMetadata;
9 |
10 | /**
11 | * Handles reporting and formatting of JAR inventory data.
12 | *
13 | *
14 | * This class is responsible for generating human-readable reports from JAR
15 | * inventory data,
16 | * including summary statistics, detailed tables, and various formatting
17 | * utilities.
18 | */
19 | class JarInventoryReport {
20 |
21 | /**
22 | * Generates a comprehensive human-readable report of JAR inventory data.
23 | *
24 | * @param jarData Collection of JAR metadata to report on
25 | * @param out PrintStream to write the report to
26 | */
27 | public static void generateReport(Collection jarData, PrintStream out) {
28 | var list = new ArrayList<>(jarData);
29 |
30 | // Sort: loaded first, then top-level before nested, then filename
31 | list.sort((a, b) -> {
32 | int cmpLoaded = Boolean.compare(b.isLoaded(), a.isLoaded());
33 | if (cmpLoaded != 0)
34 | return cmpLoaded;
35 | int cmpNest = Boolean.compare(a.isTopLevel(), b.isTopLevel()); // top-level (true) should come first
36 | if (cmpNest != 0)
37 | return -cmpNest; // invert because true > false
38 | return a.fileName.compareToIgnoreCase(b.fileName);
39 | });
40 |
41 | printSummary(list, out);
42 | printDetailedTable(list, out);
43 | }
44 |
45 | /**
46 | * Prints summary statistics about the JAR inventory.
47 | */
48 | private static void printSummary(List list, PrintStream out) {
49 | int total = list.size();
50 | long loaded = list.stream().filter(JarMetadata::isLoaded).count();
51 | long topLevel = list.stream().filter(r -> r.isTopLevel()).count();
52 | long topLevelLoaded = list.stream().filter(r -> r.isTopLevel() && r.isLoaded()).count();
53 | long nested = total - topLevel;
54 | long nestedLoaded = loaded - topLevelLoaded;
55 | long totalBytes = list.stream().filter(r -> r.size >= 0).mapToLong(r -> r.size).sum();
56 | long loadedBytes = list.stream().filter(r -> r.size >= 0 && r.isLoaded()).mapToLong(r -> r.size).sum();
57 |
58 | out.println("Summary");
59 | out.println(repeat('-', 72));
60 | out.printf("Total JARs : %d%n", total);
61 | out.printf("Loaded JARs : %d (%.1f%%) %n", loaded, percentage(loaded, total));
62 | out.printf("Top-level JARs : %d (loaded %d, %.1f%%) %n", topLevel, topLevelLoaded,
63 | percentage(topLevelLoaded, topLevel));
64 | out.printf("Nested JARs : %d (loaded %d, %.1f%%) %n", nested, nestedLoaded,
65 | percentage(nestedLoaded, nested == 0 ? 1 : nested));
66 | if (totalBytes > 0) {
67 | out.printf("Total Size : %s (%d bytes)%n", humanReadableSize(totalBytes), totalBytes);
68 | out.printf("Loaded Size : %s (%d bytes, %.1f%%) %n", humanReadableSize(loadedBytes), loadedBytes,
69 | percentage(loadedBytes, totalBytes));
70 | }
71 | out.println();
72 | }
73 |
74 | /**
75 | * Prints a detailed table of all JAR entries.
76 | */
77 | private static void printDetailedTable(List list, PrintStream out) {
78 | // Table header
79 | String header = String.format("%s %s %s %8s %12s %s %s %s",
80 | pad("#", 3), "L", "T", "SIZE", "BYTES", pad("SHA256(12)", 12), pad("FILENAME", 40), "FULL-PATH / ID");
81 |
82 | out.println("Details");
83 | out.println(repeat('-', header.length()));
84 | out.println(header);
85 | out.println(repeat('-', header.length()));
86 |
87 | int index = 1;
88 | for (JarMetadata r : list) {
89 | String idx = pad(String.valueOf(index++), 3);
90 | String l = r.isLoaded() ? "Y" : "-";
91 | String t = r.isTopLevel() ? "T" : "N"; // top-level or nested
92 | String sizeHuman = r.size >= 0 ? humanReadableSize(r.size) : "?";
93 | String sizeBytes = r.size >= 0 ? String.valueOf(r.size) : "?";
94 | String hash = pad(truncateString(r.sha256Hash, 12), 12);
95 | String name = pad(truncateString(r.fileName, 40), 40);
96 | out.printf("%s %s %s %8s %12s %s %s %s%n", idx, l, t, sizeHuman, sizeBytes, hash, name, r.fullPath);
97 | }
98 |
99 | out.println(repeat('-', header.length()));
100 | out.printf(
101 | "Legend: L=Loaded, T=Top-level, N=Nested. Size is human-readable (base 1024). Hash truncated to 12 chars.%n");
102 | }
103 |
104 | /**
105 | * Truncates a string to the specified maximum length, adding "..." if
106 | * truncated.
107 | */
108 | private static String truncateString(String s, int max) {
109 | if (s == null)
110 | return "?";
111 | if (s.length() <= max)
112 | return s;
113 | if (max <= 3)
114 | return s.substring(0, max);
115 | return s.substring(0, max - 3) + "...";
116 | }
117 |
118 | /**
119 | * Calculates percentage as a double.
120 | */
121 | private static double percentage(long part, long total) {
122 | if (total <= 0)
123 | return 0.0;
124 | return (part * 100.0) / total;
125 | }
126 |
127 | /**
128 | * Creates a string by repeating a character n times.
129 | */
130 | private static String repeat(char c, int n) {
131 | return String.valueOf(c).repeat(Math.max(0, n));
132 | }
133 |
134 | /**
135 | * Pads a string to the specified width with spaces.
136 | */
137 | private static String pad(String s, int width) {
138 | if (s == null)
139 | s = "";
140 | return s.length() >= width ? s : s + repeat(' ', width - s.length());
141 | }
142 |
143 | /**
144 | * Converts bytes to human-readable format (e.g., "1.5KB", "2.3MB").
145 | */
146 | private static String humanReadableSize(long bytes) {
147 | if (bytes < 1024)
148 | return bytes + "B";
149 |
150 | double value = bytes;
151 | String[] units = { "KB", "MB", "GB", "TB", "PB" };
152 | int unitIndex = -1;
153 |
154 | while (value >= 1024 && unitIndex < units.length - 1) {
155 | value /= 1024.0;
156 | unitIndex++;
157 | if (value < 1024 || unitIndex == units.length - 1)
158 | break;
159 | }
160 |
161 | if (unitIndex < 0)
162 | unitIndex = 0; // safety fallback
163 | return String.format("%.1f%s", value, units[unitIndex]);
164 | }
165 | }
166 |
--------------------------------------------------------------------------------
/common/src/test/java/io/github/brunoborges/jlib/common/JavaApplicationTest.java:
--------------------------------------------------------------------------------
1 | package io.github.brunoborges.jlib.common;
2 |
3 | import org.junit.jupiter.api.Test;
4 | import org.junit.jupiter.api.DisplayName;
5 | import static org.junit.jupiter.api.Assertions.*;
6 |
7 | import java.time.Instant;
8 |
9 | /**
10 | * Unit tests for JavaApplication.
11 | */
12 | @DisplayName("JavaApplication Tests")
13 | class JavaApplicationTest {
14 |
15 | @Test
16 | @DisplayName("Should create Java application with all required fields")
17 | void shouldCreateJavaApplicationWithAllRequiredFields() {
18 | String appId = "test-app-123";
19 | String commandLine = "java -jar myapp.jar";
20 | String jdkVersion = "17.0.2";
21 | String jdkVendor = "Eclipse Adoptium";
22 | String jdkPath = "/usr/lib/jvm/java-17";
23 |
24 | JavaApplication app = new JavaApplication(appId, commandLine, jdkVersion, jdkVendor, jdkPath);
25 |
26 | assertEquals(appId, app.appId);
27 | assertEquals(commandLine, app.commandLine);
28 | assertEquals(jdkVersion, app.jdkVersion);
29 | assertEquals(jdkVendor, app.jdkVendor);
30 | assertEquals(jdkPath, app.jdkPath);
31 | assertNotNull(app.firstSeen);
32 | assertNotNull(app.lastUpdated);
33 | assertTrue(app.jars.isEmpty());
34 | }
35 |
36 | @Test
37 | @DisplayName("Should initialize timestamps correctly")
38 | void shouldInitializeTimestampsCorrectly() {
39 | JavaApplication app = new JavaApplication("test", "java -jar test.jar", "17", "OpenJDK", "/java");
40 |
41 | assertTrue(app.firstSeen.compareTo(app.lastUpdated) <= 0);
42 | assertTrue(app.firstSeen.compareTo(Instant.now()) <= 0);
43 | assertTrue(app.lastUpdated.compareTo(Instant.now()) <= 0);
44 | }
45 |
46 | @Test
47 | @DisplayName("Should allow updating last updated timestamp")
48 | void shouldAllowUpdatingLastUpdatedTimestamp() throws InterruptedException {
49 | JavaApplication app = new JavaApplication("test", "java -jar test.jar", "17", "OpenJDK", "/java");
50 | Instant originalLastUpdated = app.lastUpdated;
51 |
52 | Thread.sleep(10);
53 | app.lastUpdated = Instant.now();
54 |
55 | assertTrue(app.lastUpdated.isAfter(originalLastUpdated));
56 | assertEquals(app.firstSeen, app.firstSeen); // Should not change
57 | }
58 |
59 | @Test
60 | @DisplayName("Should provide thread-safe JAR map")
61 | void shouldProvideThreadSafeJarMap() {
62 | JavaApplication app = new JavaApplication("test", "java -jar test.jar", "17", "OpenJDK", "/java");
63 |
64 | // Add some JARs
65 | JarMetadata jar1 = new JarMetadata("/path/to/jar1.jar", "jar1.jar", 1000L, "hash1");
66 | JarMetadata jar2 = new JarMetadata("/path/to/jar2.jar", "jar2.jar", 2000L, "hash2");
67 |
68 | app.jars.put(jar1.fullPath, jar1);
69 | app.jars.put(jar2.fullPath, jar2);
70 |
71 | assertEquals(2, app.jars.size());
72 | assertEquals(jar1, app.jars.get("/path/to/jar1.jar"));
73 | assertEquals(jar2, app.jars.get("/path/to/jar2.jar"));
74 | }
75 |
76 | @Test
77 | @DisplayName("Should handle null values appropriately")
78 | void shouldHandleNullValuesAppropriately() {
79 | // These should not throw exceptions
80 | JavaApplication app = new JavaApplication(null, null, null, null, null);
81 |
82 | assertNull(app.appId);
83 | assertNull(app.commandLine);
84 | assertNull(app.jdkVersion);
85 | assertNull(app.jdkVendor);
86 | assertNull(app.jdkPath);
87 | assertNotNull(app.firstSeen);
88 | assertNotNull(app.lastUpdated);
89 | assertNotNull(app.jars);
90 | }
91 |
92 | @Test
93 | @DisplayName("Should handle empty strings")
94 | void shouldHandleEmptyStrings() {
95 | JavaApplication app = new JavaApplication("", "", "", "", "");
96 |
97 | assertTrue(app.appId.isEmpty());
98 | assertTrue(app.commandLine.isEmpty());
99 | assertTrue(app.jdkVersion.isEmpty());
100 | assertTrue(app.jdkVendor.isEmpty());
101 | assertTrue(app.jdkPath.isEmpty());
102 | }
103 |
104 | @Test
105 | @DisplayName("Should support complex command lines")
106 | void shouldSupportComplexCommandLines() {
107 | String complexCommandLine = "java -Xms512m -Xmx2g -Dprop=value -javaagent:agent.jar=options -jar myapp.jar --spring.profiles.active=prod";
108 |
109 | JavaApplication app = new JavaApplication("complex-app", complexCommandLine, "17", "OpenJDK", "/java");
110 |
111 | assertEquals(complexCommandLine, app.commandLine);
112 | }
113 |
114 | @Test
115 | @DisplayName("Should handle JAR updates correctly")
116 | void shouldHandleJarUpdatesCorrectly() {
117 | JavaApplication app = new JavaApplication("test", "java -jar test.jar", "17", "OpenJDK", "/java");
118 |
119 | // Add initial JAR
120 | JarMetadata jar = new JarMetadata("/path/to/test.jar", "test.jar", 1000L, "hash1");
121 | app.jars.put(jar.fullPath, jar);
122 |
123 | assertEquals(1, app.jars.size());
124 | assertFalse(jar.isLoaded());
125 |
126 | // Update JAR (mark as loaded)
127 | jar.markLoaded();
128 | assertTrue(jar.isLoaded());
129 |
130 | // Replace with new version
131 | JarMetadata updatedJar = new JarMetadata("/path/to/test.jar", "test.jar", 1100L, "hash2");
132 | app.jars.put(updatedJar.fullPath, updatedJar);
133 |
134 | assertEquals(1, app.jars.size());
135 | assertEquals(updatedJar, app.jars.get("/path/to/test.jar"));
136 | assertEquals(1100L, app.jars.get("/path/to/test.jar").size);
137 | }
138 |
139 | @Test
140 | @DisplayName("Should handle nested JARs in application")
141 | void shouldHandleNestedJarsInApplication() {
142 | JavaApplication app = new JavaApplication("spring-app", "java -jar spring-boot-app.jar", "17", "OpenJDK", "/java");
143 |
144 | // Add main JAR
145 | JarMetadata mainJar = new JarMetadata("/app/spring-boot-app.jar", "spring-boot-app.jar", 50000L, "mainhash");
146 |
147 | // Add nested JARs
148 | JarMetadata nestedJar1 = new JarMetadata("spring-boot-app.jar!/BOOT-INF/lib/spring-core.jar", "spring-core.jar", 1000L, "hash1");
149 | JarMetadata nestedJar2 = new JarMetadata("spring-boot-app.jar!/BOOT-INF/lib/spring-context.jar", "spring-context.jar", 2000L, "hash2");
150 |
151 | app.jars.put(mainJar.fullPath, mainJar);
152 | app.jars.put(nestedJar1.fullPath, nestedJar1);
153 | app.jars.put(nestedJar2.fullPath, nestedJar2);
154 |
155 | assertEquals(3, app.jars.size());
156 |
157 | // Verify nested JAR detection
158 | assertTrue(nestedJar1.isNested());
159 | assertTrue(nestedJar2.isNested());
160 | assertFalse(mainJar.isNested());
161 |
162 | // Verify container JAR paths
163 | assertEquals("spring-boot-app.jar", nestedJar1.getContainerJarPath());
164 | assertEquals("spring-boot-app.jar", nestedJar2.getContainerJarPath());
165 | }
166 | }
167 |
--------------------------------------------------------------------------------
/server/src/main/java/io/github/brunoborges/jlib/server/handler/JarsHandler.java:
--------------------------------------------------------------------------------
1 | package io.github.brunoborges.jlib.server.handler;
2 |
3 | import com.sun.net.httpserver.HttpExchange;
4 | import com.sun.net.httpserver.HttpHandler;
5 | import io.github.brunoborges.jlib.common.JarMetadata;
6 | import io.github.brunoborges.jlib.common.JavaApplication;
7 | import io.github.brunoborges.jlib.server.service.ApplicationService;
8 | import org.json.JSONArray;
9 | import org.json.JSONObject;
10 |
11 | import java.io.IOException;
12 | import java.io.OutputStream;
13 | import java.nio.charset.StandardCharsets;
14 | import java.util.LinkedHashMap;
15 | import java.util.Map;
16 |
17 | /**
18 | * Handler exposing global JAR inventory endpoints:
19 | *
20 | * - GET /api/jars - list all known JARs (deduplicated by jarId)
21 | * - GET /api/jars/{jarId} - detail with applications that reference it
22 | *
23 | */
24 | public class JarsHandler implements HttpHandler {
25 |
26 | private final ApplicationService applicationService;
27 |
28 | public JarsHandler(ApplicationService applicationService) {
29 | this.applicationService = applicationService;
30 | }
31 |
32 | @Override
33 | public void handle(HttpExchange exchange) throws IOException {
34 | if (!"GET".equalsIgnoreCase(exchange.getRequestMethod())) {
35 | sendPlain(exchange, 405, "Method not allowed");
36 | return;
37 | }
38 | String path = exchange.getRequestURI().getPath();
39 | if ("/api/jars".equals(path)) {
40 | handleList(exchange);
41 | } else if (path.startsWith("/api/jars/")) {
42 | String jarId = path.substring("/api/jars/".length());
43 | handleDetail(exchange, jarId);
44 | } else {
45 | sendPlain(exchange, 404, "Not found");
46 | }
47 | }
48 |
49 | private void handleList(HttpExchange exchange) throws IOException {
50 | // Deduplicate by jarId, choose first occurrence for basic info and count apps
51 | class Agg {
52 | JarMetadata jar;
53 | int appCount;
54 | int loadedCount;
55 | JSONArray applicationIds = new JSONArray();
56 | }
57 | Map byId = new LinkedHashMap<>();
58 | for (JavaApplication app : applicationService.getAllApplications()) {
59 | for (JarMetadata jar : app.jars.values()) {
60 | String id = jar.getJarId();
61 | Agg agg = byId.computeIfAbsent(id, k -> {
62 | Agg a = new Agg();
63 | a.jar = jar;
64 | return a;
65 | });
66 | agg.appCount++;
67 | if (jar.isLoaded())
68 | agg.loadedCount++;
69 | // Track application id (avoid duplicates if same jar instance re-processed)
70 | // Simple linear check given typically low cardinality per jar.
71 | boolean already = false;
72 | for (int i = 0; i < agg.applicationIds.length(); i++) {
73 | if (app.appId.equals(agg.applicationIds.getString(i))) { already = true; break; }
74 | }
75 | if (!already) {
76 | agg.applicationIds.put(app.appId);
77 | }
78 | }
79 | }
80 | JSONArray arr = new JSONArray();
81 | for (Map.Entry e : byId.entrySet()) {
82 | JarMetadata jar = e.getValue().jar;
83 | JSONObject o = new JSONObject();
84 | o.put("jarId", e.getKey());
85 | o.put("fileName", jar.fileName);
86 | o.put("checksum", jar.sha256Hash);
87 | o.put("size", jar.size);
88 | o.put("appCount", e.getValue().appCount);
89 | o.put("loadedAppCount", e.getValue().loadedCount);
90 | o.put("applicationIds", e.getValue().applicationIds);
91 | arr.put(o);
92 | }
93 | sendJson(exchange, new JSONObject().put("jars", arr).toString());
94 | }
95 |
96 | private void handleDetail(HttpExchange exchange, String jarId) throws IOException {
97 | JarMetadata representative = null;
98 | JSONArray apps = new JSONArray();
99 | for (JavaApplication app : applicationService.getAllApplications()) {
100 | for (JarMetadata jar : app.jars.values()) {
101 | if (jar.getJarId().equals(jarId)) {
102 | if (representative == null)
103 | representative = jar;
104 | JSONObject a = new JSONObject();
105 | a.put("appId", app.appId);
106 | // Include application display name as appName; explicit null if unset to satisfy contract
107 | if (app.name == null) {
108 | a.put("appName", JSONObject.NULL);
109 | } else {
110 | a.put("appName", app.name);
111 | }
112 | a.put("loaded", jar.isLoaded());
113 | a.put("lastAccessed", jar.getLastAccessed().toString());
114 | a.put("path", jar.fullPath);
115 | apps.put(a);
116 | }
117 | }
118 | }
119 | if (representative == null) {
120 | sendPlain(exchange, 404, "JAR not found");
121 | return;
122 | }
123 | JSONObject root = new JSONObject();
124 | root.put("jarId", jarId);
125 | root.put("fileName", representative.fileName);
126 | root.put("checksum", representative.sha256Hash);
127 | root.put("size", representative.size);
128 | root.put("firstSeen", representative.firstSeen.toString());
129 | root.put("lastAccessed", representative.getLastAccessed().toString());
130 | if (representative.getManifestAttributes() != null && !representative.getManifestAttributes().isEmpty()) {
131 | JSONObject mf = new JSONObject();
132 | for (var e : representative.getManifestAttributes().entrySet()) {
133 | mf.put(e.getKey(), e.getValue());
134 | }
135 | root.put("manifest", mf);
136 | }
137 | root.put("applications", apps);
138 | sendJson(exchange, root.toString());
139 | }
140 |
141 | private void sendPlain(HttpExchange ex, int code, String msg) throws IOException {
142 | byte[] b = msg.getBytes(StandardCharsets.UTF_8);
143 | ex.getResponseHeaders().set("Content-Type", "text/plain; charset=UTF-8");
144 | ex.sendResponseHeaders(code, b.length);
145 | try (OutputStream os = ex.getResponseBody()) {
146 | os.write(b);
147 | }
148 | }
149 |
150 | private void sendJson(HttpExchange ex, String json) throws IOException {
151 | byte[] b = json.getBytes(StandardCharsets.UTF_8);
152 | ex.getResponseHeaders().set("Content-Type", "application/json; charset=UTF-8");
153 | ex.sendResponseHeaders(200, b.length);
154 | try (OutputStream os = ex.getResponseBody()) {
155 | os.write(b);
156 | }
157 | }
158 | }
159 |
--------------------------------------------------------------------------------
/server/src/main/java/io/github/brunoborges/jlib/server/JLibServer.java:
--------------------------------------------------------------------------------
1 | package io.github.brunoborges.jlib.server;
2 |
3 | import com.sun.net.httpserver.HttpServer;
4 |
5 | import io.github.brunoborges.jlib.common.ApplicationIdUtil;
6 | import io.github.brunoborges.jlib.server.handler.AppsHandler;
7 | import io.github.brunoborges.jlib.server.handler.HealthHandler;
8 | import io.github.brunoborges.jlib.server.handler.DashboardHandler;
9 | import io.github.brunoborges.jlib.server.service.ApplicationService;
10 | import io.github.brunoborges.jlib.server.service.JarService;
11 | import io.github.brunoborges.jlib.server.handler.ReportHandler;
12 | import io.github.brunoborges.jlib.server.handler.JarsHandler;
13 |
14 | import java.io.IOException;
15 | import java.net.InetSocketAddress;
16 | import java.util.List;
17 | import java.util.concurrent.Executors;
18 | import java.util.logging.Logger;
19 |
20 | /**
21 | * HTTP Server for tracking Java applications and their JAR file usage.
22 | *
23 | *
24 | * This server receives push events from Java agents running in other JVM
25 | * processes
26 | * and provides a REST API for querying application and JAR information.
27 | *
28 | *
API Endpoints:
29 | *
30 | * - GET /api/apps - List all tracked applications
31 | * - GET /api/apps/{appId} - Get a specific application's details
32 | * - GET /api/apps/{appId}/jars - List JARs for a specific application
33 | * - GET /api/jars - List all known JARs (deduplicated by jarId)
34 | * - GET /api/jars/{jarId} - Get details for a specific JAR across applications
35 | * - PUT /api/apps/{appId} - Register/update an application and its JARs
36 | * - PUT /api/apps/{appId}/metadata - Update application metadata (name, description, tags)
37 | * - GET /report - Aggregated unique JARs across applications
38 | * - GET /health - Health check endpoint
39 | *
40 | *
41 | * Application Data Model:
42 | *
43 | * Each Java application is identified by a hash ID computed from:
44 | *
45 | * - JVM command line arguments
46 | * - Checksums of all JAR files mentioned in the command line
47 | * - JDK version
48 | *
49 | */
50 | public class JLibServer {
51 |
52 | private static final Logger logger = Logger.getLogger(JLibServer.class.getName());
53 | private static final int PORT = 8080;
54 |
55 | private HttpServer server;
56 | private ApplicationService applicationService;
57 | private JarService jarService;
58 |
59 | /**
60 | * Starts the HTTP server on the specified port.
61 | */
62 | public void start(int port) throws IOException {
63 | // Initialize services
64 | applicationService = new ApplicationService();
65 | jarService = new JarService();
66 |
67 | // Create HTTP server
68 | server = HttpServer.create(new InetSocketAddress(port), 0);
69 | server.setExecutor(Executors.newCachedThreadPool());
70 |
71 | // Configure handlers with dependency injection
72 | server.createContext("/api/apps", new AppsHandler(applicationService, jarService));
73 | server.createContext("/api/jars", new JarsHandler(applicationService));
74 | server.createContext("/api/dashboard", new DashboardHandler(applicationService));
75 | server.createContext("/health", new HealthHandler(applicationService));
76 | server.createContext("/report", new ReportHandler(applicationService));
77 |
78 | server.start();
79 | logger.info("JLib Server started on port " + port);
80 | }
81 |
82 | /**
83 | * Stops the HTTP server.
84 | */
85 | public void stop() {
86 | if (server != null) {
87 | server.stop(0);
88 | logger.info("JLib Server stopped");
89 | }
90 | }
91 |
92 | /**
93 | * Registers a new Java application with the server.
94 | *
95 | * @param name The application name
96 | * @param commandLine The command line used to start the application
97 | * @param jarPaths List of JAR file paths on the classpath
98 | * @param jdkVersion The JDK version
99 | * @param jarChecksums List of checksums for the top-level JAR files
100 | */
101 | public void registerApplication(String name, String commandLine, List jarPaths, String jdkVersion,
102 | List jarChecksums) {
103 | String applicationId = ApplicationIdUtil.computeApplicationId(commandLine, jarChecksums, jdkVersion, "unknown",
104 | "unknown");
105 | applicationService.getOrCreateApplication(applicationId, commandLine, jdkVersion, "unknown", "unknown");
106 | logger.info("Registered application: " + applicationId + " (" + name + ")");
107 | }
108 |
109 | /**
110 | * Updates JAR information for a specific application.
111 | *
112 | * @param applicationId The application ID
113 | * @param jarPath The JAR file path
114 | * @param jarHash The JAR file hash
115 | */
116 | public void updateJarInApplication(String applicationId, String jarPath, String jarHash) {
117 | // For now, just log this - we'll need to enhance JarService for individual JAR
118 | // updates
119 | logger.info("JAR update for app " + applicationId + ": " + jarPath + " (hash: " + jarHash + ")");
120 | }
121 |
122 | /**
123 | * Main entry point for running the server.
124 | */
125 | public static void main(String[] args) {
126 | // Print production warning
127 | System.out.println("════════════════════════════════════════════════════════════════════");
128 | System.out.println("⚠️ WARNING: EXPERIMENTAL SOFTWARE - NOT PRODUCTION READY");
129 | System.out.println(" This software is in development and should only be used for");
130 | System.out.println(" development, testing, and evaluation purposes.");
131 | System.out.println(" Do not use in production environments.");
132 | System.out.println("════════════════════════════════════════════════════════════════════");
133 | System.out.println();
134 |
135 | int port = PORT;
136 | if (args.length > 0) {
137 | try {
138 | port = Integer.parseInt(args[0]);
139 | } catch (NumberFormatException e) {
140 | System.err.println("Invalid port number: " + args[0] + ". Using default port " + PORT);
141 | }
142 | }
143 |
144 | JLibServer server = new JLibServer();
145 | try {
146 | server.start(port);
147 |
148 | // Keep the server running
149 | System.out.println("JLib Server is running on port " + port);
150 | System.out.println("Press Ctrl+C to stop the server");
151 |
152 | // Add shutdown hook
153 | Runtime.getRuntime().addShutdownHook(new Thread(server::stop));
154 |
155 | // Keep main thread alive
156 | Thread.currentThread().join();
157 |
158 | } catch (IOException e) {
159 | System.err.println("Failed to start server: " + e.getMessage());
160 | System.exit(1);
161 | } catch (InterruptedException e) {
162 | Thread.currentThread().interrupt();
163 | server.stop();
164 | }
165 | }
166 | }
167 |
--------------------------------------------------------------------------------
/server/src/main/java/io/github/brunoborges/jlib/server/handler/ReportHandler.java:
--------------------------------------------------------------------------------
1 | package io.github.brunoborges.jlib.server.handler;
2 |
3 | import com.sun.net.httpserver.HttpExchange;
4 | import com.sun.net.httpserver.HttpHandler;
5 |
6 | import io.github.brunoborges.jlib.common.JarMetadata;
7 | import io.github.brunoborges.jlib.common.JavaApplication;
8 | import io.github.brunoborges.jlib.server.service.ApplicationService;
9 |
10 | import java.io.IOException;
11 | import java.io.OutputStream;
12 | import java.nio.charset.StandardCharsets;
13 | import java.time.Instant;
14 | import java.util.ArrayList;
15 | import java.util.HashMap;
16 | import java.util.HashSet;
17 | import java.util.List;
18 | import java.util.Map;
19 | import java.util.Set;
20 | import org.json.JSONArray;
21 | import org.json.JSONObject;
22 |
23 | /**
24 | * HTTP handler for /report endpoint that aggregates unique JARs across
25 | * applications.
26 | */
27 | public class ReportHandler implements HttpHandler {
28 |
29 | private final ApplicationService applicationService;
30 | // Deprecated parser usage removed.
31 |
32 | public ReportHandler(ApplicationService applicationService) {
33 | this.applicationService = applicationService;
34 | }
35 |
36 | @Override
37 | public void handle(HttpExchange exchange) throws IOException {
38 | if (!"GET".equalsIgnoreCase(exchange.getRequestMethod())) {
39 | send(exchange, 405, "Method not allowed");
40 | return;
41 | }
42 |
43 | // Aggregate by checksum when available; fallback to fullPath
44 | class Agg {
45 | String key; // checksum or fullPath
46 | String checksum;
47 | long size = -1L;
48 | String sampleFileName;
49 | Instant firstSeen = null;
50 | Instant lastAccessed = null;
51 | int loadedCount = 0;
52 | final Set paths = new HashSet<>();
53 | final Set fileNames = new HashSet<>();
54 | final List