diff --git a/.github/workflows/maven.yml b/.github/workflows/maven.yml index c8aeb3a..2c3bfcc 100644 --- a/.github/workflows/maven.yml +++ b/.github/workflows/maven.yml @@ -1,24 +1,39 @@ -# This workflow will build a Java project with Maven -# For more information see: https://help.github.com/actions/language-and-framework-guides/building-and-testing-java-with-maven +# This workflow uses actions that are not certified by GitHub. +# They are provided by a third-party and are governed by +# separate terms of service, privacy policy, and support +# documentation. -name: Java CI with Maven +name: Java CI (Maven & Gradle) on: - push: - branches: [ master ] - pull_request: - branches: [ master ] + push: + branches: [ "master" ] + pull_request: + branches: [ "master" ] jobs: - build: - - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v2 - - name: Set up JDK 1.8 - uses: actions/setup-java@v1 - with: - java-version: 1.8 - - name: Build with Maven - run: mvn -B package --file pom.xml + build: + runs-on: ubuntu-latest + + # Runs both Maven and Gradle builds to keep both build scripts healthy + strategy: + matrix: + build-tool: [maven, gradle] + + steps: + - uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + cache: ${{ matrix.build-tool }} + + - name: Build with Maven + if: matrix.build-tool == 'maven' + run: mvn -B package --file pom.xml + + - name: Build with Gradle + if: matrix.build-tool == 'gradle' + run: ./gradlew --no-daemon clean build diff --git a/.gitignore b/.gitignore index f15597a..241c1af 100644 --- a/.gitignore +++ b/.gitignore @@ -13,11 +13,14 @@ target # IntelliJ # *.iml .idea/ -.gradle/ -gradle/ +.gradle +gradle/* +!gradle/wrapper/ +!gradle/wrapper/gradle-wrapper.jar +!gradle/wrapper/gradle-wrapper.properties + +build/ libraries/ -gradlew -gradlew.bat logs/ log-test/ diff --git a/README.md b/README.md index 092a74a..4e6211f 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ netconf-java ============ +**A modernized Java library for NETCONF (now Java 17‑compatible)** + Java library for NETCONF SUPPORT @@ -13,12 +15,24 @@ or even better submit pull requests on GitHub. REQUIREMENTS ============ -[OpenJDK 8](http://openjdk.java.net/projects/jdk8/) or Java 8 -[Maven](https://maven.apache.org/download.cgi) if you want to build using `mvn` [Supported from v2.1.1]. +* [OpenJDK 17](https://openjdk.org/projects/jdk/17/) or later +* [Maven](https://maven.apache.org/download.cgi) if you want to build using `mvn` [Supported from v2.1.1]. +* [Gradle 8+](https://gradle.org/releases/) if you prefer a Gradle build (`./gradlew build`) -[Maven](https://maven.apache.org/download.cgi) if you want to build using `mvn` [Supported from v2.1.1]. +Building +======== +You can build the project using **Maven** or **Gradle**. -[lombok](https://mvnrepository.com/artifact/org.projectlombok/lombok) needs to be provided by the runtime (`mvn dependency scope` is set as `provided`) +### Maven +```bash +mvn clean package +``` + +### Gradle +```bash +./gradlew clean build +``` +(The wrapper script downloads the correct Gradle version automatically.) Releases ======== @@ -38,6 +52,17 @@ User may download the source code and compile it with desired JDK version. ======= +v2.2.0 +------ +* Java 17 baseline; compiled with `--release 17` +* Gradle build added alongside Maven +* SpotBugs upgraded to 6.x +* Added **:confirmed-commit:1.1** support (`commitConfirm(timeout, persist)` and `cancelCommit(persistId)`) +* Added **killSession(String)** helper for RFC 6241 §7.9 +* Auto‑inject base 1.1 capability in <hello> exchange +* Gradle wrapper committed; GitHub Actions now builds Maven *and* Gradle +* Expanded Javadoc and SpotBugs clean‑up + v2.1.1 ------ @@ -59,6 +84,7 @@ Example: Device device = Device.builder().hostName("hostname") .userName("username") .password("password") + .connectionTimeout(2000) .hostKeysFileName("hostKeysFileName") .build(); ``` @@ -84,6 +110,7 @@ public class ShowInterfaces { .hostName("hostname") .userName("username") .password("password") + .connectionTimeout(2000) .hostKeysFileName("hostKeysFileName") .build(); device.connect(); @@ -127,3 +154,4 @@ AUTHOR [Ankit Jain](http://www.linkedin.com/in/ankitj093), Juniper Networks [Peter J Hill](https://github.com/peterjhill), Oracle +[Community Contributors](https://github.com/Juniper/netconf-java/graphs/contributors) diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..bf158a1 --- /dev/null +++ b/build.gradle @@ -0,0 +1,107 @@ +plugins { + id 'java' + id 'jacoco' + id 'maven-publish' + id 'com.github.spotbugs' version '6.0.6' +} + +group = 'net.juniper.netconf' +version = '2.2.0.0' +description = 'An API For NetConf client' + +java { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + withJavadocJar() + withSourcesJar() +} + +repositories { + mavenCentral() +} + +dependencies { + implementation 'com.jcraft:jsch:0.1.55' + implementation 'org.slf4j:slf4j-api:2.0.3' + implementation 'com.google.guava:guava:32.0.1-jre' + + testImplementation 'org.hamcrest:hamcrest-all:1.3' + testImplementation 'org.assertj:assertj-core:3.23.1' + testImplementation 'org.mockito:mockito-core:4.8.1' + testImplementation 'commons-io:commons-io:2.14.0' + testImplementation 'org.xmlunit:xmlunit-assertj:2.9.0' + testImplementation 'org.slf4j:slf4j-simple:2.0.3' + testImplementation 'com.github.spotbugs:spotbugs-annotations:4.7.3' + +} + +testing { + suites { + test { + // Pulls in both junit-jupiter-api and -engine at version 5.11.1 + useJUnitJupiter('5.11.1') + } + } +} + +test { + testLogging { + events "passed", "skipped", "failed" + } +} + +tasks.withType(JavaCompile).configureEach { + options.fork = true + options.forkOptions.jvmArgs += [ + '--add-opens=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED', + '--add-opens=jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED', + '--add-opens=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED', + '--add-opens=jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED', + '--add-opens=jdk.compiler/com.sun.tools.javac.model=ALL-UNNAMED', + '--add-opens=jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED', + '--add-opens=jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED', + '--add-opens=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED', + '--add-opens=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED', + '--add-opens=jdk.compiler/com.sun.tools.javac.jvm=ALL-UNNAMED' + ] +} + +spotbugs { + ignoreFailures = true +} + +publishing { + publications { + mavenJava(MavenPublication) { + from components.java + pom { + name.set("$group:$project.name") + description.set(project.description) + url.set("https://github.com/Juniper/netconf-java") + + licenses { + license { + name.set("BSD 2") + url.set("https://opensource.org/licenses/BSD-2-Clause") + } + } + + developers { + developer { + id.set("juniper") + name.set("Juniper Networks") + email.set("jnpr-community-netdev@juniper.net") + organization.set("Juniper Networks") + organizationUrl.set("https://github.com/Juniper") + } + } + + scm { + connection.set("scm:git:git://github.com/Juniper/netconf-java.git") + developerConnection.set("scm:git:ssh://github.com:Juniper/netconf-java.git") + url.set("https://github.com/Juniper/netconf-java/tree/master") + } + } + } + } +} \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..1b33c55 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..b82aa23 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..23d15a9 --- /dev/null +++ b/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..5eed7ee --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/pom.xml b/pom.xml index 57440bf..7eb9718 100644 --- a/pom.xml +++ b/pom.xml @@ -7,7 +7,7 @@ net.juniper.netconf netconf-java - 2.1.1.7 + 2.2.0.0 jar @@ -45,133 +45,38 @@ - org.codehaus.mojo - findbugs-maven-plugin - 3.0.5 - - true - Max - Medium - true - ${project.build.directory}/findbugs - + com.github.spotbugs + spotbugs-maven-plugin + 4.8.6.0 - analyze-compile - test - - check - + verify + check + + false + Max + Low + + org.apache.maven.plugins maven-surefire-plugin 3.0.0-M3 true + false - - org.projectlombok - lombok-maven-plugin - 1.18.20.0 - - - generate-sources - - delombok - - - - - src/main/java - ${project.build.directory}/delombok - false - UTF-8 - - - - - maven-resources-plugin - 3.2.0 - - - copy-to-lombok-build - process-resources - - copy-resources - - - - - ${project.basedir}/src/main/resources - - - ${project.build.directory}/delombok - - - - - - - maven-antrun-plugin - 1.8 - - - generate-delomboked-sources-jar - package - - run - - - - - - - - - - - - maven-install-plugin - 2.5.2 - - - install-source-jar - - install-file - - install - - ${project.build.directory}/${project.build.finalName}-sources.jar - ${project.groupId} - ${project.artifactId} - ${project.version} - sources - true - ${project.basedir}/pom.xml - - - - - maven-compiler-plugin 3.8.0 - 1.8 - 1.8 - - - org.projectlombok - lombok - 1.18.22 - - + 17 + 17 -J--add-opens=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED -J--add-opens=jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED @@ -208,7 +113,10 @@ jar - 1.8 + 17 + false + all + -Xdoclint:none @@ -226,6 +134,9 @@ true ${basedir} javadoc + false + all + java Juniper Networks, Inc.]]> @@ -253,12 +164,6 @@ jsch 0.1.55 - - org.projectlombok - lombok - provided - 1.18.24 - org.slf4j slf4j-api @@ -300,13 +205,13 @@ commons-io commons-io test - 2.11.0 + 2.14.0 com.google.guava guava - 31.1-jre + 32.0.1-jre @@ -316,6 +221,13 @@ test + + org.junit.jupiter + junit-jupiter + 5.11.1 + test + + org.slf4j slf4j-simple @@ -323,5 +235,12 @@ test + + com.github.spotbugs + spotbugs-annotations + 4.7.3 + test + + diff --git a/src/main/java/net/juniper/netconf/Device.java b/src/main/java/net/juniper/netconf/Device.java index 208b724..2168a14 100644 --- a/src/main/java/net/juniper/netconf/Device.java +++ b/src/main/java/net/juniper/netconf/Device.java @@ -12,19 +12,14 @@ import com.jcraft.jsch.JSch; import com.jcraft.jsch.JSchException; import com.jcraft.jsch.Session; -import lombok.Builder; -import lombok.Getter; -import lombok.NonNull; -import lombok.ToString; -import lombok.extern.slf4j.Slf4j; import net.juniper.netconf.element.Datastore; import net.juniper.netconf.element.Hello; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.w3c.dom.Document; import org.xml.sax.SAXException; import javax.xml.parsers.DocumentBuilder; -import javax.xml.parsers.DocumentBuilderFactory; -import javax.xml.parsers.ParserConfigurationException; import java.io.BufferedReader; import java.io.IOException; import java.io.InputStream; @@ -32,6 +27,7 @@ import java.nio.charset.Charset; import java.util.Arrays; import java.util.List; +import java.util.Objects; /** * A Device is used to define a Netconf server. @@ -56,11 +52,10 @@ * {@link #close() close()} method. * */ -@Slf4j -@Getter -@ToString public class Device implements AutoCloseable { + private static final Logger log = LoggerFactory.getLogger(Device.class); + private static final int DEFAULT_NETCONF_PORT = 830; private static final int DEFAULT_TIMEOUT = 5000; private static final List DEFAULT_CLIENT_CAPABILITIES = Arrays.asList( @@ -86,7 +81,7 @@ public class Device implements AutoCloseable { private final boolean strictHostKeyChecking; private final String hostKeysFileName; - private final DocumentBuilder builder; + private final DocumentBuilder xmlBuilder; private final List netconfCapabilities; private final String helloRpc; @@ -94,75 +89,301 @@ public class Device implements AutoCloseable { private Session sshSession; private NetconfSession netconfSession; - @Builder - public Device(JSch sshClient, - @NonNull String hostName, - Integer port, - Integer timeout, - Integer connectionTimeout, - Integer commandTimeout, - @NonNull String userName, - String password, - Boolean keyBasedAuthentication, - String pemKeyFile, - Boolean strictHostKeyChecking, - String hostKeysFileName, - List netconfCapabilities - ) throws NetconfException { - this.hostName = hostName; - this.port = (port != null) ? port : DEFAULT_NETCONF_PORT; - Integer commonTimeout = (timeout != null) ? timeout : DEFAULT_TIMEOUT; - this.connectionTimeout = (connectionTimeout != null) ? connectionTimeout : commonTimeout; - this.commandTimeout = (commandTimeout != null) ? commandTimeout : commonTimeout; + /** + * Returns a new {@link Builder} for constructing {@link Device} instances. + * + * @return fresh {@link Builder} + */ + public static Builder builder() { + return new Builder(); + } - this.userName = userName; - this.password = password; + /** + * Fluent builder for {@link Device}. Configure desired fields and call + * {@link #build()} to obtain an immutable instance. + */ + public static final class Builder { + /** + * Creates an empty {@code Builder}. + */ + private Builder() { + } - if (this.password == null && pemKeyFile == null) { - throw new NetconfException("Auth requires either setting the password or the pemKeyFile"); + private JSch sshClient = new JSch(); + private String hostName; + private int port = DEFAULT_NETCONF_PORT; + private int timeout = DEFAULT_TIMEOUT; + private Integer connectionTimeout; + private Integer commandTimeout; + private String userName; + private String password; + private boolean keyAuth = false; + private String pemKeyFile; + private boolean strictHostKeyChecking = true; + private String hostKeysFileName; + private List netconfCapabilities = DEFAULT_CLIENT_CAPABILITIES; + + + /** + * Replaces the default {@link JSch} instance with a caller‑supplied one. + *

+ * Supplying your own {@code JSch} lets you pre‑configure global settings + * like proxies or identity repositories before a {@link Device} is built. + * + * @param sshClient pre‑configured {@link JSch} instance (must not be {@code null}) + * @return this {@code Builder} for fluent chaining + * @throws NullPointerException if {@code sshClient} is {@code null} + */ + public Builder sshClient(JSch sshClient) { + this.sshClient = Objects.requireNonNull(sshClient); + return this; } - this.keyBasedAuthentication = (keyBasedAuthentication != null) ? keyBasedAuthentication : false; - this.pemKeyFile = pemKeyFile; + /** + * Sets the DNS host name or IP address of the Netconf server. + * + * @param hostName server host name or IP + * @return this {@code Builder} for fluent chaining + */ + public Builder hostName(String hostName) { + this.hostName = hostName; + return this; + } - if (this.keyBasedAuthentication && pemKeyFile == null) { - throw new NetconfException("key based authentication requires setting the pemKeyFile"); + /** + * Specifies the TCP port on which the Netconf SSH subsystem listens. + *

+ * Defaults to {@code 830} if not set. + * + * @param port TCP port number + * @return this {@code Builder} for fluent chaining + */ + public Builder port(int port) { + this.port = port; + return this; } - this.strictHostKeyChecking = (strictHostKeyChecking != null) ? strictHostKeyChecking : true; - this.hostKeysFileName = hostKeysFileName; + /** + * Sets a default timeout (in milliseconds) that is used when a more + * specific {@link #connectionTimeout(int)} or {@link #commandTimeout(int)} + * value has not been provided. + * + * @param ms timeout in milliseconds + * @return this {@code Builder} for fluent chaining + */ + public Builder timeout(int ms) { + this.timeout = ms; + return this; + } - if (this.strictHostKeyChecking && hostKeysFileName == null) { - throw new NetconfException("Strict Host Key checking requires setting the hostKeysFileName"); + /** + * Overrides the default SSH connection timeout. + * + * @param ms timeout in milliseconds for establishing the SSH session + * @return this {@code Builder} for fluent chaining + */ + public Builder connectionTimeout(int ms) { + this.connectionTimeout = ms; + return this; } - DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); - try { - builder = factory.newDocumentBuilder(); - } catch (ParserConfigurationException e) { - throw new NetconfException(String.format("Error creating XML Parser: %s", e.getMessage()), e); + /** + * Sets the per‑command timeout that applies to individual NETCONF RPCs + * (distinct from the SSH connection timeout). + * + * @param ms timeout in milliseconds + * @return this {@code Builder} for fluent chaining + */ + public Builder commandTimeout(int ms) { + this.commandTimeout = ms; + return this; + } + + /** + * Specifies the login user name for the SSH/NETCONF session. + * + * @param userName user name string + * @return this {@code Builder} for fluent chaining + */ + public Builder userName(String userName) { + this.userName = userName; + return this; + } + + /** + * Sets the password used for password‑based SSH authentication. + *

+ * Ignored if {@link #keyBasedAuth(String)} is used instead. + * + * @param password login password + * @return this {@code Builder} for fluent chaining + */ + public Builder password(String password) { + this.password = password; + return this; + } + + /** + * Enables key‑based SSH authentication and sets the path to the PEM + * private‑key file. + * + * @param pem absolute or relative path to the PEM‑formatted private key + * @return this {@code Builder} for fluent chaining + */ + public Builder keyBasedAuth(String pem) { + this.keyAuth = true; + this.pemKeyFile = pem; + return this; + } + + /** + * Specifies the path to the PEM‑formatted private key that will be used + * for key‑based SSH authentication. + *

+ * Note: calling this method alone does not switch the builder + * to key‑authentication mode; be sure to also invoke + * {@link #keyBasedAuth(String)} or set {@link #keyAuth} explicitly. + * + * @param pemKeyFile absolute or relative path to the PEM key file + * @return this {@code Builder} for fluent chaining + */ + public Builder pemKeyFile(String pemKeyFile) { + this.pemKeyFile = pemKeyFile; + return this; + } + + /** + * Disables strict host‑key checking so every host key is trusted + * (equivalent to {@code StrictHostKeyChecking=no} in OpenSSH). + *

+ * Security note: Accepting all host keys makes the + * connection vulnerable to man‑in‑the‑middle attacks. Use this only in + * development or other low‑risk environments. + * + * @return this {@code Builder} for fluent chaining + */ + public Builder trustAllHostKeys() { + this.strictHostKeyChecking = false; + return this; + } + + /** + * Enables or disables strict host‑key checking for the SSH session. + *

+ * When set to {@code true} the underlying JSch session will verify the + * server's host key against the known‑hosts file and refuse the + * connection if it is unknown. When {@code false} the connection will + * proceed even if the server's host key is not listed. + * + * @param strictHostKeyChecking whether to enforce host‑key checking + * @return this {@code Builder} for fluent chaining + */ + public Builder strictHostKeyChecking(boolean strictHostKeyChecking) { + this.strictHostKeyChecking = strictHostKeyChecking; + return this; } - this.netconfCapabilities = (netconfCapabilities != null) ? netconfCapabilities : getDefaultClientCapabilities(); - helloRpc = createHelloRPC(this.netconfCapabilities); + /** + * Specifies the path to the SSH known‑hosts file used when + * {@link #strictHostKeyChecking(boolean)} is enabled. + * + * @param hostKeysFileName absolute or relative path to the known‑hosts file + * @return this {@code Builder} for fluent chaining + */ + public Builder hostKeysFileName(String hostKeysFileName) { + this.hostKeysFileName = hostKeysFileName; + return this; + } + + /** + * Replaces the default list of client‑side Netconf capabilities that + * will be advertised in the initial {@code <hello>} message. + *

+ * The supplied list is defensively copied and wrapped in an + * unmodifiable view, so subsequent modifications to the original list + * do not affect the builder. + * + * @param caps list of capability URIs; must not be {@code null} + * @return this {@code Builder} for fluent chaining + * @throws NullPointerException if {@code caps} is {@code null} + */ + public Builder netconfCapabilities(List caps) { + this.netconfCapabilities = java.util.Collections.unmodifiableList(new java.util.ArrayList<>(caps)); + return this; + } + + /** + * Validates all required fields and constructs an immutable {@link Device}. + *

+ * Mandatory parameters include host name, user credentials (or key), and + * host‑key settings when strict checking is enabled. If any of these are + * missing or inconsistent, a {@link NetconfException} is thrown. + * + * @return a fully‑configured {@link Device} instance + * @throws NetconfException if validation fails or if an internal error + * occurs while initialising auxiliary resources + */ + public Device build() throws NetconfException { + // Validation logic moved from Device constructor + if (hostName == null) throw new NetconfException("hostName is required"); + if (userName == null) throw new NetconfException("userName is required"); + if (!keyAuth && password == null) + throw new NetconfException("Password is required for password auth"); + if (strictHostKeyChecking && hostKeysFileName == null) + throw new NetconfException("hostKeysFileName required when strictHostKeyChecking=true"); + if (keyAuth && pemKeyFile == null) + throw new NetconfException("pemKeyFile required when keyAuth=true"); + try { + return new Device(this); + } catch (NetconfException e) { + throw e; + } + } + } + + /* ------------------------------------------------------------------ + * Private constructor used by Builder + * ------------------------------------------------------------------ */ + private Device(Builder b) throws NetconfException { + this.sshClient = b.sshClient; + this.hostName = b.hostName; + this.port = b.port; + this.connectionTimeout = b.connectionTimeout != null ? b.connectionTimeout : b.timeout; + this.commandTimeout = b.commandTimeout != null ? b.commandTimeout : b.timeout; + + this.userName = b.userName; + this.password = b.password; + this.keyBasedAuthentication = b.keyAuth; + this.pemKeyFile = b.pemKeyFile; + this.strictHostKeyChecking = b.strictHostKeyChecking; + this.hostKeysFileName = b.hostKeysFileName; + + this.netconfCapabilities = b.netconfCapabilities; + + try { + this.xmlBuilder = javax.xml.parsers.DocumentBuilderFactory.newInstance().newDocumentBuilder(); + } catch (javax.xml.parsers.ParserConfigurationException e) { + throw new NetconfException("Cannot create XML Parser", e); + } - this.sshClient = (sshClient != null) ? sshClient : new JSch(); + this.helloRpc = createHelloRPC(this.netconfCapabilities); } + /** * Get the client capabilities that are advertised to the Netconf server by default. * RFC 6241 describes the standard netconf capabilities. - * https://tools.ietf.org/html/rfc6241#section-8 + * ... * * @return List of default client capabilities. */ - private List getDefaultClientCapabilities() { + protected List getDefaultClientCapabilities() { return DEFAULT_CLIENT_CAPABILITIES; } /** * Given a list of netconf capabilities, generate the netconf hello rpc message. - * https://tools.ietf.org/html/rfc6241#section-8.1 + * ... * * @param capabilities A list of netconf capabilities * @return the hello RPC that represents those capabilities. @@ -215,10 +436,10 @@ private NetconfSession createNetconfSession() throws NetconfException { try { sshChannel = (ChannelSubsystem) sshSession.openChannel("subsystem"); sshChannel.setSubsystem("netconf"); - return new NetconfSession(sshChannel, connectionTimeout, commandTimeout, helloRpc, builder); + return new NetconfSession(sshChannel, connectionTimeout, commandTimeout, helloRpc, xmlBuilder); } catch (JSchException | IOException e) { throw new NetconfException("Failed to create Netconf session:" + - e.getMessage(), e); + e.getMessage(), e); } } @@ -232,7 +453,7 @@ private Session loginWithUserPass(int timeoutMilliSeconds) throws NetconfExcepti return session; } catch (JSchException e) { throw new NetconfException(String.format("Error connecting to host: %s - Error: %s", - hostName, e.getMessage()), e); + hostName, e.getMessage()), e); } } @@ -245,7 +466,7 @@ private Session loginWithPrivateKey(int timeoutMilliSeconds) throws NetconfExcep return session; } catch (JSchException e) { throw new NetconfException(String.format("Error using key pair file: %s to connect to host: %s - Error: %s", - pemKeyFile, hostName, e.getMessage()), e); + pemKeyFile, hostName, e.getMessage()), e); } } @@ -256,9 +477,9 @@ private Session loginWithPrivateKey(int timeoutMilliSeconds) throws NetconfExcep */ public void connect() throws NetconfException { if (hostName == null || userName == null || (password == null && - pemKeyFile == null)) { + pemKeyFile == null)) { throw new NetconfException("Login parameters of Device can't be " + - "null."); + "null."); } netconfSession = this.createNetconfSession(); } @@ -280,11 +501,17 @@ private void loadPrivateKey() throws NetconfException { public String reboot() throws IOException { if (netconfSession == null) { throw new IllegalStateException("Cannot execute RPC, you need to " + - "establish a connection first."); + "establish a connection first."); } return this.netconfSession.reboot(); } + /** + * Indicates whether both the SSH {@link Session} and {@link ChannelSubsystem} + * are currently connected. + * + * @return {@code true} if the device is connected + */ public boolean isConnected() { return (isChannelConnected() && isSessionConnected()); } @@ -370,7 +597,7 @@ public String runShellCommand(String command) throws IOException { * @throws IOException if there are issues communicating with the Netconf server. */ public BufferedReader runShellCommandRunning(String command) - throws IOException { + throws IOException { if (!isConnected()) { throw new IOException("Could not find open connection"); } @@ -385,9 +612,9 @@ public BufferedReader runShellCommandRunning(String command) } /** - * Send an RPC(as String object) over the default Netconf session and get - * the response as an XML object. - *

+ * Send an RPC(as String object) over the default Netconf session and get the + * response as an XML object. + *

Convenience overload for raw‑string payloads.

* * @param rpcContent RPC content to be sent. For example, to send an rpc * <rpc><get-chassis-inventory/></rpc>, the @@ -402,7 +629,7 @@ public BufferedReader runShellCommandRunning(String command) public XML executeRPC(String rpcContent) throws SAXException, IOException { if (netconfSession == null) { throw new IllegalStateException("Cannot execute RPC, you need to " + - "establish a connection first."); + "establish a connection first."); } return netconfSession.executeRPC(rpcContent); } @@ -410,7 +637,7 @@ public XML executeRPC(String rpcContent) throws SAXException, IOException { /** * Send an RPC(as XML object) over the Netconf session and get the response * as an XML object. - *

+ *

Use when the payload is already assembled as an {@link XML} helper object.

* * @param rpc RPC to be sent. Use the XMLBuilder to create RPC as an * XML object. @@ -421,7 +648,7 @@ public XML executeRPC(String rpcContent) throws SAXException, IOException { public XML executeRPC(XML rpc) throws SAXException, IOException { if (netconfSession == null) { throw new IllegalStateException("Cannot execute RPC, you need to " + - "establish a connection first."); + "establish a connection first."); } return this.netconfSession.executeRPC(rpc); } @@ -429,7 +656,8 @@ public XML executeRPC(XML rpc) throws SAXException, IOException { /** * Send an RPC(as Document object) over the Netconf session and get the * response as an XML object. - *

+ *

Accepts a DOM {@link org.w3c.dom.Document} that represents the full + * <rpc> element.

* * @param rpcDoc RPC content to be sent, as a org.w3c.dom.Document object. * @return RPC reply sent by Netconf server @@ -439,31 +667,24 @@ public XML executeRPC(XML rpc) throws SAXException, IOException { public XML executeRPC(Document rpcDoc) throws SAXException, IOException { if (netconfSession == null) { throw new IllegalStateException("Cannot execute RPC, you need to " + - "establish a connection first."); + "establish a connection first."); } return this.netconfSession.executeRPC(rpcDoc); } /** - * Send an RPC(as String object) over the default Netconf session and get - * the response as a BufferedReader. - *

+ * Sends an RPC (as a raw XML string) over the default Netconf session and + * returns a {@link BufferedReader} for streaming the reply. * - * @param rpcContent RPC content to be sent. For example, to send an rpc - * <rpc><get-chassis-inventory/></rpc>, the - * String to be passed can be - * "<get-chassis-inventory/>" OR - * "get-chassis-inventory" OR - * "<rpc><get-chassis-inventory/></rpc>" - * @return RPC reply sent by Netconf server as a BufferedReader. This is - * useful if we want continuous stream of output, rather than wait - * for whole output till rpc execution completes. - * @throws java.io.IOException if there are errors communicating with the Netconf server. + * @param rpcContent XML payload to send (content of the <rpc> element) + * @return RPC reply as a {@link BufferedReader} + * @throws IOException if communication with the server fails + * @throws IllegalStateException if no Netconf connection exists */ public BufferedReader executeRPCRunning(String rpcContent) throws IOException { if (netconfSession == null) { throw new IllegalStateException("Cannot execute RPC, you need to " + - "establish a connection first."); + "establish a connection first."); } return this.netconfSession.executeRPCRunning(rpcContent); } @@ -471,7 +692,7 @@ public BufferedReader executeRPCRunning(String rpcContent) throws IOException { /** * Send an RPC(as XML object) over the Netconf session and get the response * as a BufferedReader. - *

+ *

Streams the reply incrementally, suitable for large responses.

* * @param rpc RPC to be sent. Use the XMLBuilder to create RPC as an * XML object. @@ -483,26 +704,34 @@ public BufferedReader executeRPCRunning(String rpcContent) throws IOException { public BufferedReader executeRPCRunning(XML rpc) throws IOException { if (netconfSession == null) { throw new IllegalStateException("Cannot execute RPC, you need to " + - "establish a connection first."); + "establish a connection first."); } return this.netconfSession.executeRPCRunning(rpc); } /** - * Send an RPC(as Document object) over the Netconf session and get the - * response as a BufferedReader. + * Sends an RPC (as a DOM {@link Document}) over the active NETCONF session + * and returns a {@link BufferedReader} for streaming the reply. *

+ * Use this variant when you need to consume the server response + * incrementally — for example, when the RPC produces a + * large dataset or when you want to start processing output before the + * device finishes sending the final ]]>]]> prompt. + *

* - * @param rpcDoc RPC content to be sent, as a org.w3c.dom.Document object. - * @return RPC reply sent by Netconf server as a BufferedReader. This is - * useful if we want continuous stream of output, rather than wait - * for whole output till command execution completes. - * @throws java.io.IOException If there are errors communicating with the Netconf server. + * @param rpcDoc the complete <rpc> element encoded as a DOM + * {@link Document}; must not be {@code null} + * + * @return a {@link BufferedReader} connected to the server’s reply stream + * + * @throws IOException if an I/O error occurs while sending the + * request or reading the reply + * @throws IllegalStateException if no NETCONF connection is established */ public BufferedReader executeRPCRunning(Document rpcDoc) throws IOException { if (netconfSession == null) { throw new IllegalStateException("Cannot execute RPC, you need to " + - "establish a connection first."); + "establish a connection first."); } return this.netconfSession.executeRPCRunning(rpcDoc); } @@ -516,7 +745,7 @@ public BufferedReader executeRPCRunning(Document rpcDoc) throws IOException { public String getSessionId() { if (netconfSession == null) { throw new IllegalStateException("Cannot get session ID, you need " + - "to establish a connection first."); + "to establish a connection first."); } return this.netconfSession.getSessionId(); } @@ -532,7 +761,7 @@ public String getSessionId() { public boolean hasError() throws SAXException, IOException { if (netconfSession == null) { throw new IllegalStateException("No RPC executed yet, you need to" + - " establish a connection first."); + " establish a connection first."); } return this.netconfSession.hasError(); } @@ -547,7 +776,7 @@ public boolean hasError() throws SAXException, IOException { public boolean hasWarning() throws SAXException, IOException { if (netconfSession == null) { throw new IllegalStateException("No RPC executed yet, you need to " + - "establish a connection first."); + "establish a connection first."); } return this.netconfSession.hasWarning(); } @@ -562,7 +791,7 @@ public boolean hasWarning() throws SAXException, IOException { public boolean isOK() { if (netconfSession == null) { throw new IllegalStateException("No RPC executed yet, you need to " + - "establish a connection first."); + "establish a connection first."); } return this.netconfSession.isOK(); } @@ -578,7 +807,7 @@ public boolean isOK() { public boolean lockConfig() throws IOException, SAXException { if (netconfSession == null) { throw new IllegalStateException("Cannot execute RPC, you need to " + - "establish a connection first."); + "establish a connection first."); } return this.netconfSession.lockConfig(); } @@ -593,7 +822,7 @@ public boolean lockConfig() throws IOException, SAXException { public boolean unlockConfig() throws IOException, SAXException { if (netconfSession == null) { throw new IllegalStateException("Cannot execute RPC, you need to " + - "establish a connection first."); + "establish a connection first."); } return this.netconfSession.unlockConfig(); } @@ -610,10 +839,10 @@ public boolean unlockConfig() throws IOException, SAXException { * @throws org.xml.sax.SAXException If there are errors parsing the XML reply. */ public void loadXMLConfiguration(String configuration, String loadType) - throws IOException, SAXException { + throws IOException, SAXException { if (netconfSession == null) { throw new IllegalStateException("Cannot execute RPC, you need to " + - "establish a connection first."); + "establish a connection first."); } this.netconfSession.loadXMLConfiguration(configuration, loadType); } @@ -634,10 +863,10 @@ public void loadXMLConfiguration(String configuration, String loadType) * @throws org.xml.sax.SAXException If there are errors parsing the XML reply. */ public void loadTextConfiguration(String configuration, String loadType) - throws IOException, SAXException { + throws IOException, SAXException { if (netconfSession == null) { throw new IllegalStateException("Cannot execute RPC, you need to " + - "establish a connection first."); + "establish a connection first."); } this.netconfSession.loadTextConfiguration(configuration, loadType); } @@ -655,11 +884,11 @@ public void loadTextConfiguration(String configuration, String loadType) * @throws org.xml.sax.SAXException If there are errors parsing the XML reply. */ public void loadSetConfiguration(String configuration) throws - IOException, - SAXException { + IOException, + SAXException { if (netconfSession == null) { throw new IllegalStateException("Cannot execute RPC, you need to " + - "establish a connection first."); + "establish a connection first."); } this.netconfSession.loadSetConfiguration(configuration); } @@ -675,10 +904,10 @@ public void loadSetConfiguration(String configuration) throws * @throws org.xml.sax.SAXException If there are errors parsing the XML reply. */ public void loadXMLFile(String configFile, String loadType) - throws IOException, SAXException { + throws IOException, SAXException { if (netconfSession == null) { throw new IllegalStateException("Cannot execute RPC, you need to " + - "establish a connection first."); + "establish a connection first."); } this.netconfSession.loadXMLFile(configFile, loadType); } @@ -694,10 +923,10 @@ public void loadXMLFile(String configFile, String loadType) * @throws org.xml.sax.SAXException If there are errors parsing the XML reply. */ public void loadTextFile(String configFile, String loadType) - throws IOException, SAXException { + throws IOException, SAXException { if (netconfSession == null) { throw new IllegalStateException("Cannot execute RPC, you need to " + - "establish a connection first."); + "establish a connection first."); } this.netconfSession.loadTextFile(configFile, loadType); } @@ -713,10 +942,10 @@ public void loadTextFile(String configFile, String loadType) * @throws org.xml.sax.SAXException If there are errors parsing the XML reply. */ public void loadSetFile(String configFile) throws - IOException, SAXException { + IOException, SAXException { if (netconfSession == null) { throw new IllegalStateException("Cannot execute RPC, you need to " + - "establish a connection first."); + "establish a connection first."); } this.netconfSession.loadSetFile(configFile); } @@ -731,7 +960,7 @@ public void loadSetFile(String configFile) throws public void commit() throws CommitException, IOException, SAXException { if (netconfSession == null) { throw new IllegalStateException("Cannot execute RPC, you need to " + - "establish a connection first."); + "establish a connection first."); } this.netconfSession.commit(); } @@ -747,10 +976,10 @@ public void commit() throws CommitException, IOException, SAXException { * @throws org.xml.sax.SAXException If there are errors parsing the XML reply. */ public void commitConfirm(long seconds) throws CommitException, IOException, - SAXException { + SAXException { if (netconfSession == null) { throw new IllegalStateException("Cannot execute RPC, you need to " + - "establish a connection first."); + "establish a connection first."); } this.netconfSession.commitConfirm(seconds); } @@ -766,7 +995,7 @@ public void commitConfirm(long seconds) throws CommitException, IOException, public void commitFull() throws CommitException, IOException, SAXException { if (netconfSession == null) { throw new IllegalStateException("Cannot execute RPC, you need to " + - "establish a connection first."); + "establish a connection first."); } this.netconfSession.commitFull(); } @@ -778,10 +1007,10 @@ public void commitFull() throws CommitException, IOException, SAXException { * @param configFile Path name of file containing configuration,in text/xml format, * to be loaded. For example, * "system { - * services { - * ftp; - * } - * }" + * services { + * ftp; + * } + * }" * will load 'ftp' under the 'systems services' hierarchy. * OR * "<configuration><system><services><ftp/>< @@ -793,10 +1022,10 @@ public void commitFull() throws CommitException, IOException, SAXException { * @throws org.xml.sax.SAXException If there are errors parsing the XML reply. */ public void commitThisConfiguration(String configFile, String loadType) - throws CommitException, IOException, SAXException { + throws CommitException, IOException, SAXException { if (netconfSession == null) { throw new IllegalStateException("Cannot execute RPC, you need to " + - "establish a connection first."); + "establish a connection first."); } this.netconfSession.commitThisConfiguration(configFile, loadType); } @@ -812,10 +1041,10 @@ public void commitThisConfiguration(String configFile, String loadType) * @throws org.xml.sax.SAXException If there are errors parsing the XML reply. */ public XML getCandidateConfig(String configTree) throws SAXException, - IOException { + IOException { if (netconfSession == null) { throw new IllegalStateException("Cannot execute RPC, you need to " + - "establish a connection first."); + "establish a connection first."); } return this.netconfSession.getCandidateConfig(configTree); } @@ -831,10 +1060,10 @@ public XML getCandidateConfig(String configTree) throws SAXException, * @throws org.xml.sax.SAXException If there are errors parsing the XML reply. */ public XML getRunningConfig(String configTree) throws SAXException, - IOException { + IOException { if (netconfSession == null) { throw new IllegalStateException("Cannot execute RPC, you need to " + - "establish a connection first."); + "establish a connection first."); } return this.netconfSession.getRunningConfig(configTree); } @@ -842,7 +1071,7 @@ public XML getRunningConfig(String configTree) throws SAXException, /** * Retrieve the running configuration, or part of the configuration. * - * @param xpathFilter example + * @param xpathFilter example {@code <filter xmlns:model='urn:path:for:my:model' select='/model:*' />} * @return configuration data as XML object. * @throws java.io.IOException If there are errors communicating with the netconf server. * @throws org.xml.sax.SAXException If there are errors parsing the XML reply. @@ -850,27 +1079,28 @@ public XML getRunningConfig(String configTree) throws SAXException, public XML getRunningConfigAndState(String xpathFilter) throws IOException, SAXException { if (netconfSession == null) { throw new IllegalStateException("Cannot execute RPC, you need to " + - "establish a connection first."); + "establish a connection first."); } return this.netconfSession.getRunningConfigAndState(xpathFilter); } /** - * Run the call to netconf server and retrieve data as an XML. + * Run the {@code <get-data>} call to netconf server and retrieve data as an XML. * - * @param xpathFilter example - * @param datastore running, startup, candidate, or operational + * @param xpathFilter example {@code <filter xmlns:model='urn:path:for:my:model' select='/model:*' />} + * @param datastore running, startup, candidate, or operational * @return configuration data as XML object. * @throws java.io.IOException If there are errors communicating with the netconf server. * @throws org.xml.sax.SAXException If there are errors parsing the XML reply. */ - public XML getData(String xpathFilter, @NonNull Datastore datastore) throws IOException, SAXException { + public XML getData(String xpathFilter, Datastore datastore) throws IOException, SAXException { + if (datastore == null) throw new NullPointerException("datastore must not be null"); if (netconfSession == null) { throw new IllegalStateException("Cannot execute RPC, you need to " + - "establish a connection first."); + "establish a connection first."); } - return this.netconfSession.getData(xpathFilter, datastore); + return this.netconfSession.getData(xpathFilter, datastore); } @@ -884,7 +1114,7 @@ public XML getData(String xpathFilter, @NonNull Datastore datastore) throws IOEx public XML getCandidateConfig() throws SAXException, IOException { if (netconfSession == null) { throw new IllegalStateException("Cannot execute RPC, you need to " + - "establish a connection first."); + "establish a connection first."); } return this.netconfSession.getCandidateConfig(); } @@ -899,7 +1129,7 @@ public XML getCandidateConfig() throws SAXException, IOException { public XML getRunningConfig() throws SAXException, IOException { if (netconfSession == null) { throw new IllegalStateException("Cannot execute RPC, you need to " + - "establish a connection first."); + "establish a connection first."); } return this.netconfSession.getRunningConfig(); } @@ -914,7 +1144,7 @@ public XML getRunningConfig() throws SAXException, IOException { public boolean validate() throws IOException, SAXException { if (netconfSession == null) { throw new IllegalStateException("Cannot execute RPC, you need to " + - "establish a connection first."); + "establish a connection first."); } return this.netconfSession.validate(); } @@ -931,7 +1161,7 @@ public boolean validate() throws IOException, SAXException { public String runCliCommand(String command) throws IOException, SAXException { if (netconfSession == null) { throw new IllegalStateException("Cannot execute RPC, you need to " + - "establish a connection first."); + "establish a connection first."); } return this.netconfSession.runCliCommand(command); } @@ -948,7 +1178,7 @@ public String runCliCommand(String command) throws IOException, SAXException { public BufferedReader runCliCommandRunning(String command) throws IOException { if (netconfSession == null) { throw new IllegalStateException("Cannot execute RPC, you need to " + - "establish a connection first."); + "establish a connection first."); } return this.netconfSession.runCliCommandRunning(command); } @@ -964,7 +1194,7 @@ public BufferedReader runCliCommandRunning(String command) throws IOException { public void openConfiguration(String mode) throws IOException { if (netconfSession == null) { throw new IllegalStateException("Cannot execute RPC, you need to " + - "establish a connection first."); + "establish a connection first."); } netconfSession.openConfiguration(mode); } @@ -978,7 +1208,7 @@ public void openConfiguration(String mode) throws IOException { public void closeConfiguration() throws IOException { if (netconfSession == null) { throw new IllegalStateException("Cannot execute RPC, you need to " + - "establish a connection first."); + "establish a connection first."); } netconfSession.closeConfiguration(); } @@ -1008,6 +1238,7 @@ public void createRPCAttribute(String name, String value) { /** * Removes an RPC attribute from the default rpc xml envelope used in all rpc executions. * + * @param name the attribute name to remove * @return The value of the removed attribute. * @throws NullPointerException If the device connection has not been made yet. */ @@ -1024,4 +1255,289 @@ public void clearRPCAttributes() { netconfSession.removeAllRPCAttributes(); } + /** + * Convenience alias for {@link #getStrictHostKeyChecking()}. + * + * @return {@code true} if strict host-key checking is enabled + */ + public boolean isStrictHostKeyChecking() { + return this.strictHostKeyChecking; + } + // Getters for fields + + /** + * Returns the {@link JSch} instance that backs this {@code Device}. + *

+ * Note – the returned object is the live instance + * used for all SSH operations; creating a defensive copy is not feasible, + * so callers must not modify its global state (e.g. changing + * the identity repository or host‑key repository) once the {@code Device} + * has been built. + * + * @return the underlying {@link JSch} SSH client + */ + @SuppressWarnings("EI_EXPOSE_REP") // Defensive copy not feasible for JSch + public JSch getSshClient() { + // Defensive copy not possible; document that caller must not modify + return sshClient; + } + + /** + * Returns the DNS host name or IP address of the NETCONF server to which + * this {@code Device} instance will attempt to connect. + * + * @return host name or IP string + */ + public String getHostName() { + return hostName; + } + + /** + * Returns the TCP port on which the remote device’s NETCONF SSH subsystem + * is listening. The default is {@code 830} unless explicitly overridden + * in the builder. + * + * @return NETCONF port number + */ + public int getPort() { + return port; + } + + /** + * Returns the maximum time, in milliseconds, to wait while establishing + * the underlying SSH transport connection before giving up. + * + * @return SSH connection timeout (milliseconds) + */ + public int getConnectionTimeout() { + return connectionTimeout; + } + + /** + * Returns the per‑command timeout configured for individual NETCONF RPCs. + * + * @return timeout in milliseconds + */ + public int getCommandTimeout() { + return commandTimeout; + } + + /** + * Returns the user name that will be used to authenticate the SSH session. + * + * @return login user name + */ + public String getUserName() { + return userName; + } + + /** + * Returns the password associated with {@link #getUserName()}, or + * {@code null} if key‑based authentication is configured. + * + * @return password string, or {@code null} + */ + public String getPassword() { + return password; + } + + /** + * Indicates whether key‑based SSH authentication is configured instead + * of password‑based authentication. + * + * @return {@code true} if key‑based authentication is configured + */ + public boolean isKeyBasedAuthentication() { + return keyBasedAuthentication; + } + + /** + * Returns the path to the PEM‑formatted private‑key file that will be + * used for key‑based authentication. + * + * @return PEM key file path, or {@code null} if password auth is used + */ + public String getPemKeyFile() { + return pemKeyFile; + } + + /** + * Returns whether strict host-key checking is enabled for this device. + * + * @return {@code true} if strict host-key checking is on + */ + public boolean getStrictHostKeyChecking() { + return strictHostKeyChecking; + } + + /** + * Returns the path to the SSH known-hosts file used for host-key checking. + * + * @return known-hosts file path, or {@code null} if none was provided + */ + public String getHostKeysFileName() { + return hostKeysFileName; + } + + /** + * Returns the DocumentBuilder used for XML parsing. + *

+ * Defensive copy not possible; caller must not modify the returned instance. + * + * @return the {@link DocumentBuilder} in use + */ + @SuppressWarnings("EI_EXPOSE_REP") // Defensive copy not feasible for DocumentBuilder + public DocumentBuilder getXmlBuilder() { + // Defensive copy not possible; document that caller must not modify + return xmlBuilder; + } + + /** + * Returns an immutable copy of the capability URIs that this client + * advertises in its initial {@code <hello>} exchange. + * + * @return list of Netconf capability URIs + */ + public List getNetconfCapabilities() { + return new java.util.ArrayList<>(netconfCapabilities); + } + + /** + * Returns the pre‑built NETCONF {@code <hello>} RPC payload that + * will be sent when a session is established. + * + * @return initial NETCONF {@code <hello>} RPC string + */ + public String getHelloRpc() { + return helloRpc; + } + + /** + * Returns the active SSH {@link ChannelSubsystem} used for NETCONF + * communication, or {@code null} if not yet connected. + * + * @return active SSH subsystem channel, or {@code null} + */ + @SuppressWarnings("EI_EXPOSE_REP") // Defensive copy not feasible for active channel + public ChannelSubsystem getSshChannel() { + return sshChannel; // Defensive copy not feasible; caller must not modify + } + + /** + * Returns the underlying SSH {@link Session}, or {@code null} if not + * connected. + * + * @return SSH session, or {@code null} + */ + @SuppressWarnings("EI_EXPOSE_REP") // Defensive copy not feasible for active session + public Session getSshSession() { + return sshSession; // Defensive copy not feasible; caller must not modify + } + + /** + * Returns the current {@link NetconfSession}, or {@code null} if no + * session has been established. + * + * @return active Netconf session, or {@code null} + */ + @SuppressWarnings("EI_EXPOSE_REP") // Defensive copy not feasible for active session + public NetconfSession getNetconfSession() { + return netconfSession; // Defensive copy not feasible; caller must not modify + } + + // Setters for mutable fields (if needed) + + /** + * Sets the SSH channel subsystem. + *

+ * Defensive copy not feasible; ensures non-null reference. + * The caller must not reuse or externally share the provided object after setting. + * + * @param sshChannel the SSH channel subsystem (must not be null) + * @throws NullPointerException if sshChannel is null + */ + @SuppressWarnings("EI_EXPOSE_REP2") // Defensive copy not feasible for active channel + public void setSshChannel(ChannelSubsystem sshChannel) { + this.sshChannel = (ChannelSubsystem) Objects.requireNonNull(sshChannel); + } + + /** + * Sets the SSH session. + *

+ * Defensive copy not feasible; ensures non-null reference. + * The caller must not reuse or externally share the provided object after setting. + * + * @param sshSession the SSH session (must not be null) + * @throws NullPointerException if sshSession is null + */ + @SuppressWarnings("EI_EXPOSE_REP2") // Defensive copy not feasible for active session + public void setSshSession(Session sshSession) { + this.sshSession = Objects.requireNonNull(sshSession); + } + + /** + * Sets the Netconf session. + *

+ * Defensive copy not feasible; ensures non-null reference. + * The caller must not reuse or externally share the provided object after setting. + * + * @param netconfSession the Netconf session (must not be null) + * @throws NullPointerException if netconfSession is null + */ + @SuppressWarnings("EI_EXPOSE_REP2") // Defensive copy not feasible for active session + public void setNetconfSession(NetconfSession netconfSession) { + this.netconfSession = Objects.requireNonNull(netconfSession); + } + + // equals(), hashCode(), toString() + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + Device device = (Device) o; + return port == device.port && + connectionTimeout == device.connectionTimeout && + commandTimeout == device.commandTimeout && + keyBasedAuthentication == device.keyBasedAuthentication && + strictHostKeyChecking == device.strictHostKeyChecking && + java.util.Objects.equals(sshClient, device.sshClient) && + java.util.Objects.equals(hostName, device.hostName) && + java.util.Objects.equals(userName, device.userName) && + java.util.Objects.equals(password, device.password) && + java.util.Objects.equals(pemKeyFile, device.pemKeyFile) && + java.util.Objects.equals(hostKeysFileName, device.hostKeysFileName) && + java.util.Objects.equals(xmlBuilder, device.xmlBuilder) && + java.util.Objects.equals(netconfCapabilities, device.netconfCapabilities) && + java.util.Objects.equals(helloRpc, device.helloRpc); + } + + @Override + public int hashCode() { + return java.util.Objects.hash( + sshClient, hostName, port, connectionTimeout, commandTimeout, + userName, password, keyBasedAuthentication, pemKeyFile, + strictHostKeyChecking, hostKeysFileName, xmlBuilder, + netconfCapabilities, helloRpc + ); + } + + @Override + public String toString() { + return "Device{" + + "sshClient=" + sshClient + + ", hostName='" + hostName + '\'' + + ", port=" + port + + ", connectionTimeout=" + connectionTimeout + + ", commandTimeout=" + commandTimeout + + ", userName='" + userName + '\'' + + ", password='" + (password != null ? "***" : null) + '\'' + + ", keyBasedAuthentication=" + keyBasedAuthentication + + ", pemKeyFile='" + pemKeyFile + '\'' + + ", strictHostKeyChecking=" + strictHostKeyChecking + + ", hostKeysFileName='" + hostKeysFileName + '\'' + + ", xmlBuilder=" + xmlBuilder + + ", netconfCapabilities=" + netconfCapabilities + + ", helloRpc='" + helloRpc + '\'' + + '}'; + } } \ No newline at end of file diff --git a/src/main/java/net/juniper/netconf/LoadException.java b/src/main/java/net/juniper/netconf/LoadException.java index e11108a..253d012 100644 --- a/src/main/java/net/juniper/netconf/LoadException.java +++ b/src/main/java/net/juniper/netconf/LoadException.java @@ -3,19 +3,50 @@ All Rights Reserved Use is subject to license terms. - */ package net.juniper.netconf; import java.io.IOException; -/** - * Describes exceptions related to load operation +/** + * Exception thrown when a load RPC returns <rpc-error> or otherwise + * fails to complete successfully. + * + *

Three convenient constructors are provided so callers can supply: + *

    + *
  1. a human‑readable message only,
  2. + *
  3. a message and root cause, or
  4. + *
  5. just the root cause.
  6. + *
*/ public class LoadException extends IOException { - LoadException(String msg) { - super(msg); + /** + * Creates a {@code LoadException} with the supplied message. + * + * @param message description of the load failure + */ + public LoadException(String message) { + super(message); + } + + /** + * Creates a {@code LoadException} with a message and a root cause. + * + * @param message description of the load failure + * @param cause underlying exception that triggered the failure + */ + public LoadException(String message, Throwable cause) { + super(message, cause); + } + + /** + * Creates a {@code LoadException} that wraps an underlying cause. + * + * @param cause underlying exception that triggered the failure + */ + public LoadException(Throwable cause) { + super(cause); } } diff --git a/src/main/java/net/juniper/netconf/NetconfConstants.java b/src/main/java/net/juniper/netconf/NetconfConstants.java index 6baa28e..c664540 100644 --- a/src/main/java/net/juniper/netconf/NetconfConstants.java +++ b/src/main/java/net/juniper/netconf/NetconfConstants.java @@ -1,38 +1,70 @@ package net.juniper.netconf; /** + * Centralised collection of string literals and protocol constants used + * throughout the NETCONF client library. + *

+ * The class is {@code final} and has a private constructor – it cannot be + * instantiated or extended. All members are {@code public static final} + * to encourage direct use without additional indirection. + *

+ * * @author Jonas Glass */ public class NetconfConstants { - private NetconfConstants() { - } + /* ------------------------------------------------------------------ + * Framing protocol + * ------------------------------------------------------------------ */ /** - * Device prompt for the framing protocol. - * https://tools.ietf.org/html/rfc6242#section-4.1 + * Device prompt used by the NETCONF chunked framing protocol. + * + * @see RFC 6242 §4.1 */ public static final String DEVICE_PROMPT = "]]>]]>"; + /* ------------------------------------------------------------------ + * XML preamble & namespaces + * ------------------------------------------------------------------ */ + /** - * XML Schema prefix. + * XML declaration emitted at the top of NETCONF messages. */ public static final String XML_VERSION = ""; /** - * XML Namespace for NETCONF Base 1.0 - * https://tools.ietf.org/html/rfc6241#section-8.1 + * XML namespace for NETCONF Base 1.0 + * + * @see RFC 6241 §8.1 */ public static final String URN_XML_NS_NETCONF_BASE_1_0 = "urn:ietf:params:xml:ns:netconf:base:1.0"; /** - * URN for NETCONF Base 1.0 - * https://tools.ietf.org/html/rfc6241#section-8.1 + * URI form of the NETCONF Base 1.0 capability identifier. + * + * @see RFC 6241 §8.1 */ public static final String URN_IETF_PARAMS_NETCONF_BASE_1_0 = "urn:ietf:params:netconf:base:1.0"; + /* ------------------------------------------------------------------ + * Misc helpers + * ------------------------------------------------------------------ */ + + /** Empty line helper constant. */ public static final String EMPTY_LINE = ""; + + /** Line feed (Unix‑style newline). */ public static final String LF = "\n"; + + /** Carriage return (use with {@code LF} for CRLF sequences). */ public static final String CR = "\r"; + /** UTF‑8 charset literal used throughout the library. */ + public static final String CHARSET_UTF8 = "utf-8"; + + /** + * Not instantiable – utility holder only. + */ + private NetconfConstants() { /* no‑op */ } } diff --git a/src/main/java/net/juniper/netconf/NetconfException.java b/src/main/java/net/juniper/netconf/NetconfException.java index 7120d2d..bc50a69 100644 --- a/src/main/java/net/juniper/netconf/NetconfException.java +++ b/src/main/java/net/juniper/netconf/NetconfException.java @@ -14,10 +14,22 @@ * Describes exceptions related to establishing Netconf session. */ public class NetconfException extends IOException { + /** + * Constructs a {@code NetconfException} with the specified detail message. + * + * @param msg the detail message that describes the exception + */ public NetconfException(String msg) { super(msg); } + /** + * Constructs a {@code NetconfException} with the specified detail message + * and underlying cause. + * + * @param msg the detail message + * @param t the throwable that caused this exception + */ public NetconfException(String msg, Throwable t) { super(msg, t); } diff --git a/src/main/java/net/juniper/netconf/NetconfSession.java b/src/main/java/net/juniper/netconf/NetconfSession.java index 72c87ef..5b25835 100644 --- a/src/main/java/net/juniper/netconf/NetconfSession.java +++ b/src/main/java/net/juniper/netconf/NetconfSession.java @@ -12,8 +12,6 @@ import com.google.common.base.Charsets; import com.jcraft.jsch.Channel; import com.jcraft.jsch.JSchException; -import lombok.NonNull; -import lombok.extern.slf4j.Slf4j; import net.juniper.netconf.element.Datastore; import net.juniper.netconf.element.Hello; import net.juniper.netconf.element.RpcReply; @@ -44,24 +42,24 @@ import java.util.concurrent.TimeUnit; /** - * A NetconfSession object is used to call the Netconf driver - * methods. - * This is derived by creating a Device first, - * and calling createNetconfSession(). + * A {@code NetconfSession} is obtained by first building a + * {@link Device} and then calling {@link Device#connect()}. *

* Typically, one *

    - *
  1. creates a Device object.
  2. - *
  3. calls the createNetconfSession() method to get a NetconfSession - * object.
  4. - *
  5. perform operations on the NetconfSession object.
  6. - *
  7. finally, one must close the NetconfSession and release resources with - * the {@link #close() close()} method.
  8. + *
  9. Build a {@link Device} using its fluent {@code builder()}.
  10. + *
  11. Invoke {@link Device#connect()} to establish transport and receive a + * {@code NetconfSession}.
  12. + *
  13. Perform RPC operations via the session + * (e.g. {@link #executeRPC(String)}, {@link #killSession(String)}, + * {@link #commitConfirm(long, String)}, {@link #cancelCommit(String)}).
  14. + *
  15. Call {@link #close()} when finished to free resources.
  16. *
*/ -@Slf4j public class NetconfSession { + private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(NetconfSession.class); + private final Channel netconfChannel; private String serverCapability; private Hello serverHello; @@ -78,7 +76,12 @@ public class NetconfSession { private String rpcAttributes; private int messageId = 0; - // Bigger than inner buffer in BufferReader class + /** + * Size (in characters) of the temporary read buffer used when collecting + * RPC replies from the device. Set larger than the internal buffer in + * {@link java.io.BufferedReader} so that large replies are less likely to + * require multiple passes. + */ public static final int BUFFER_SIZE = 9 * 1024; private static final String CANDIDATE_CONFIG = "candidate"; @@ -226,9 +229,16 @@ public void loadXMLConfiguration(String configuration, String loadType) throws I "" + "" + NetconfConstants.DEVICE_PROMPT; - setLastRpcReply(getRpcReply(rpc)); - if (hasError() || !isOK()) + try { + setLastRpcReply(getRpcReply(rpc)); + } catch (NetconfException e) { + // Propagate as a LoadException so the caller knows this happened + throw new LoadException("Load operation returned error.", e); + } + + if (hasError() || !isOK()) { throw new LoadException("Load operation returned error."); + } } private void setHelloReply(final String reply) throws IOException { @@ -282,9 +292,15 @@ public void loadTextConfiguration(String configuration, String loadType) throws "" + "" + NetconfConstants.DEVICE_PROMPT; - setLastRpcReply(getRpcReply(rpc)); - if (hasError() || !isOK()) + try { + setLastRpcReply(getRpcReply(rpc)); + } catch (NetconfException e) { + throw new LoadException("Load operation returned error.", e); + } + + if (hasError() || !isOK()) { throw new LoadException("Load operation returned error"); + } } private String getConfig(String configTree) throws IOException { @@ -304,6 +320,16 @@ private String getConfig(String configTree) throws IOException { return lastRpcReply; } + /** + * Executes a NETCONF {@code <get>} operation against the device’s running + * datastore and returns the configuration **and** operational state data. + * + * @param xpathFilter optional XPath filter to limit the returned subtree; + * pass {@code null} to retrieve the entire running state + * @return an {@link XML} object representing the server’s {@code <rpc-reply>} + * @throws IOException if communication with the device fails + * @throws SAXException if the reply cannot be parsed into valid XML + */ public XML getRunningConfigAndState(String xpathFilter) throws IOException, SAXException { String rpc = "" + "" + @@ -315,9 +341,24 @@ public XML getRunningConfigAndState(String xpathFilter) throws IOException, SAXE return convertToXML(lastRpcReply); } - public XML getData(String xpathFilter, @NonNull Datastore datastore) + /** + * Executes the YANG NMDA {@code <get-data>} operation against the specified + * {@link Datastore}. + * + * @param xpathFilter optional XPath filter that narrows the returned data; + * may be {@code null} for an unfiltered request + * @param datastore the target datastore (e.g., {@code operational}, {@code running}); + * must not be {@code null} + * @return an {@link XML} object containing the server’s reply + * @throws IllegalArgumentException if {@code datastore} is {@code null} + * @throws IOException if communication with the device fails + * @throws SAXException if the reply XML is malformed + */ + public XML getData(String xpathFilter, Datastore datastore) throws IOException, SAXException { - + if (datastore == null) { + throw new IllegalArgumentException("Datastore argument must not be null"); + } String rpc = "" + "" + "ds:" + datastore + "" + @@ -355,7 +396,8 @@ public String getServerCapability() { } /** - * Returns the <hello> message received from the server. See https://datatracker.ietf.org/doc/html/rfc6241#section-8.1 + * Returns the <hello> message received from the server. + * See ... * @return the <hello> message received from the server. */ public Hello getServerHello() { @@ -363,30 +405,34 @@ public Hello getServerHello() { } /** - * Send an RPC(as String object) over the default Netconf session and get - * the response as an XML object. + * Sends a raw RPC string, waits for the {@code <rpc-reply>}, and converts the + * response to an {@link XML} object. *

+ * If the server includes any {@code <rpc-error>} elements, the call + * now throws a {@link NetconfException}. This makes error handling + * symmetrical with other high‑level helpers (e.g. {@code load*()}, + * {@code commit()}). * - * @param rpcContent RPC content to be sent. For example, to send an rpc - * <rpc><get-chassis-inventory/></rpc>, the - * String to be passed can be - * "<get-chassis-inventory/>" OR - * "get-chassis-inventory" OR - * "<rpc><get-chassis-inventory/></rpc>" - * @return RPC reply sent by Netconf server - * @throws org.xml.sax.SAXException If the XML Reply cannot be parsed. - * @throws java.io.IOException If there are issues communicating with the netconf server. + * @param rpcContent the RPC payload (with or without <rpc> wrapper) + * @return parsed {@link XML} representation of the reply + * @throws NetconfException if the reply contains one or more + * {@code <rpc-error>} elements + * @throws SAXException if the reply cannot be parsed as XML + * @throws IOException on transport errors */ public XML executeRPC(String rpcContent) throws SAXException, IOException { String rpcReply = getRpcReply(fixupRpc(rpcContent)); setLastRpcReply(rpcReply); + + if (hasError()) { + throw new NetconfException("RPC returned error: " + rpcReply); + } return convertToXML(rpcReply); } /** - * Send an RPC(as XML object) over the Netconf session and get the response + * Send an RPC (as XML object) over the Netconf session and get the response * as an XML object. - *

* * @param rpc RPC to be sent. Use the XMLBuilder to create RPC as an * XML object. @@ -399,9 +445,8 @@ public XML executeRPC(XML rpc) throws SAXException, IOException { } /** - * Send an RPC(as Document object) over the Netconf session and get the + * Send an RPC (as Document object) over the Netconf session and get the * response as an XML object. - *

* * @param rpcDoc RPC content to be sent, as a org.w3c.dom.Document object. * @return RPC reply sent by Netconf server @@ -417,14 +462,14 @@ public XML executeRPC(Document rpcDoc) throws SAXException, IOException { /** * Given an RPC command, wrap it in RPC tags. - * https://tools.ietf.org/html/rfc6241#section-4.1 + * ... * * @param rpcContent an RPC command that may or may not be wrapped in < or > * @return a string of the RPC command wrapped in <rpc>< ></rpc> * @throws IllegalArgumentException if null is passed in as the rpcContent. */ @VisibleForTesting - static String fixupRpc(@NonNull String rpcContent) throws IllegalArgumentException { + static String fixupRpc(String rpcContent) throws IllegalArgumentException { if (rpcContent == null) { throw new IllegalArgumentException("Null RPC"); } @@ -440,9 +485,8 @@ static String fixupRpc(@NonNull String rpcContent) throws IllegalArgumentExcepti /** - * Send an RPC(as String object) over the default Netconf session and get + * Send an RPC (as String object) over the default Netconf session and get * the response as a BufferedReader. - *

* * @param rpcContent RPC content to be sent. For example, to send an rpc * <rpc><get-chassis-inventory/></rpc>, the @@ -460,9 +504,8 @@ public BufferedReader executeRPCRunning(String rpcContent) throws IOException { } /** - * Send an RPC(as XML object) over the Netconf session and get the response + * Send an RPC (as XML object) over the Netconf session and get the response * as a BufferedReader. - *

* * @param rpc RPC to be sent. Use the XMLBuilder to create RPC as an * XML object. @@ -476,9 +519,8 @@ public BufferedReader executeRPCRunning(XML rpc) throws IOException { } /** - * Send an RPC(as Document object) over the Netconf session and get the + * Send an RPC (as Document object) over the Netconf session and get the * response as a BufferedReader. - *

* * @param rpcDoc RPC content to be sent, as a org.w3c.dom.Document object. * @return RPC reply sent by Netconf server as a BufferedReader. This is @@ -520,10 +562,8 @@ public void close() throws IOException { * Check if the last RPC reply returned from Netconf server has any error. * * @return true if any errors are found in last RPC reply. - * @throws org.xml.sax.SAXException If the XML Reply cannot be parsed. - * @throws java.io.IOException If there are issues communicating with the netconf server. */ - public boolean hasError() throws SAXException, IOException { + public boolean hasError() { return lastRpcReplyObject.hasErrors(); } @@ -531,10 +571,8 @@ public boolean hasError() throws SAXException, IOException { * Check if the last RPC reply returned from Netconf server has any warning. * * @return true if any errors are found in last RPC reply. - * @throws org.xml.sax.SAXException If the XML Reply cannot be parsed. - * @throws java.io.IOException If there are issues communicating with the netconf server. */ - public boolean hasWarning() throws SAXException, IOException { + public boolean hasWarning() { return lastRpcReplyObject.hasWarnings(); } @@ -545,7 +583,7 @@ public boolean hasWarning() throws SAXException, IOException { * @return true if <ok/> tag is found in last RPC reply. */ public boolean isOK() { - return lastRpcReplyObject.isOk(); + return lastRpcReplyObject.isOK(); } /** @@ -572,10 +610,9 @@ public boolean lockConfig() throws IOException, SAXException { * Unlocks the candidate configuration. * * @return true if successful. - * @throws org.xml.sax.SAXException If the XML Reply cannot be parsed. * @throws java.io.IOException If there are issues communicating with the netconf server. */ - public boolean unlockConfig() throws IOException, SAXException { + public boolean unlockConfig() throws IOException { String rpc = "" + "" + "" + @@ -588,6 +625,35 @@ public boolean unlockConfig() throws IOException, SAXException { return !hasError() && isOK(); } + /** + * Terminates another active NETCONF session on the server + * (RFC 6241 §7.9). + *

+ * The server will abort any operations in progress for that session, + * release resources and locks, and close the connection. + * + * @param sessionId the session identifier to terminate; must not be {@code null} or empty + * @return {@code true} if the operation succeeded (no <rpc-error> and an <ok/> was returned) + * @throws IllegalArgumentException if {@code sessionId} is {@code null} or empty + * @throws IOException if communication with the device fails + * @throws SAXException if the reply cannot be parsed + */ + public boolean killSession(String sessionId) throws IOException, SAXException { + if (sessionId == null || sessionId.isEmpty()) { + throw new IllegalArgumentException("sessionId must not be null or empty"); + } + + String rpc = "" + + "" + + "" + sessionId + "" + + "" + + "" + + NetconfConstants.DEVICE_PROMPT; + + setLastRpcReply(getRpcReply(rpc)); + return !hasError() && isOK(); + } + /** * Loads the candidate configuration, Configuration should be in set * format. @@ -597,10 +663,9 @@ public boolean unlockConfig() throws IOException, SAXException { * "set system services ftp" * will load 'ftp' under the 'systems services' hierarchy. * To load multiple set statements, separate them by '\n' character. - * @throws org.xml.sax.SAXException If there are issues parsing the config file. * @throws java.io.IOException If there are issues reading the config file. */ - public void loadSetConfiguration(String configuration) throws IOException, SAXException { + public void loadSetConfiguration(String configuration) throws IOException { String rpc = "" + "" + "" + @@ -650,7 +715,7 @@ private void validateLoadType(String loadType) throws IllegalArgumentException { */ private String readConfigFile(String configFile) throws IOException { try { - return new String(Files.readAllBytes(Paths.get(configFile)), Charset.defaultCharset().name()); + return Files.readString(Paths.get(configFile), Charset.defaultCharset()); } catch (FileNotFoundException e) { throw new FileNotFoundException("The system cannot find the configuration file specified: " + configFile); } @@ -679,11 +744,10 @@ public void loadTextFile(String configFile, String loadType) throws IOException, * * @param configFile Path name of file containing configuration,in set format, * to be loaded. - * @throws org.xml.sax.SAXException If there are issues parsing the config file. * @throws java.io.IOException If there are issues reading the config file. */ public void loadSetFile(String configFile) throws - IOException, SAXException { + IOException { loadSetConfiguration(readConfigFile(configFile)); } @@ -748,26 +812,79 @@ public void commit() throws IOException, SAXException { } /** - * Commit the candidate configuration, temporarily. This is equivalent of - * 'commit confirm' + * Sends a *confirmed* <commit> request that follows the original + * RFC 6241 §8.4 semantics (confirmed‑commit **1.0**). + *

+ * The server will roll back the candidate configuration unless a + * non‑confirmed <commit> is issued from **the same session** + * within the given timeout period. + *

* - * @param seconds Time in seconds, after which the previous active configuration - * is reverted back to. - * @throws java.io.IOException If there are errors communicating with the netconf server. - * @throws org.xml.sax.SAXException If there are errors parsing the XML reply. + * @param seconds the <confirm-timeout> in seconds; if {@code seconds + * <= 0} the device’s default (600 s) is used + * @throws IOException if communication with the device fails + * + * @deprecated Prefer {@link #commitConfirm(long, String)} which adds + * the <persist> / <persist-id> + * parameters required by the + * :confirmed-commit:1.1 capability. */ - public void commitConfirm(long seconds) throws IOException, SAXException { - String rpc = "" + - "" + - "" + - "" + seconds + "" + - "" + - "" + - NetconfConstants.DEVICE_PROMPT; - setLastRpcReply(getRpcReply(rpc)); - if (hasError() || !isOK()) - throw new CommitException("Commit operation returned " + - "error."); + @Deprecated + public void commitConfirm(long seconds) throws IOException { + commitConfirm(seconds, null); + } + + /** + * Issues a confirmed commit as defined by the + * + * :confirmed-commit:1.1 capability. + * + * @param seconds confirm‑timeout (600 s default). If {@code seconds <= 0} + * the timeout element is omitted and the server default + * is used. + * @param persistToken optional token for cross‑session confirmation; if + * {@code null} the commit can only be confirmed from + * the same session. + * @throws IOException if communication with the device fails + */ + public void commitConfirm(long seconds, String persistToken) throws IOException { + StringBuilder rpc = new StringBuilder(); + rpc.append(""); + if (seconds > 0) { + rpc.append("").append(seconds).append(""); + } + if (persistToken != null) { + rpc.append("").append(persistToken).append(""); + } + rpc.append("").append(NetconfConstants.DEVICE_PROMPT); + + setLastRpcReply(getRpcReply(rpc.toString())); + if (hasError() || !isOK()) { + throw new CommitException("Confirmed-commit operation returned error."); + } + } + + /** + * Cancels a pending confirmed commit. + * + * @param persistId optional token that matches the <persist> used + * in the original confirmed commit; may be {@code null} + * if no token was supplied. + * @return {@code true} if the server replied <ok/> + * @throws IOException if communication with the device fails + * @throws SAXException if the reply cannot be parsed + */ + public boolean cancelCommit(String persistId) throws IOException, SAXException { + StringBuilder rpc = new StringBuilder(); + rpc.append(""); + if (persistId != null) { + rpc.insert(rpc.length() - "".length(), + "" + persistId + ""); + } + rpc.append("").append(NetconfConstants.DEVICE_PROMPT); + + setLastRpcReply(getRpcReply(rpc.toString())); + return !hasError() && isOK(); } /** @@ -775,9 +892,8 @@ public void commitConfirm(long seconds) throws IOException, SAXException { * * @throws net.juniper.netconf.CommitException if there is an error committing the config. * @throws java.io.IOException If there are errors communicating with the netconf server. - * @throws org.xml.sax.SAXException If there are errors parsing the XML reply. */ - public void commitFull() throws CommitException, IOException, SAXException { + public void commitFull() throws CommitException, IOException { String rpc = "" + "" + "" + @@ -847,9 +963,8 @@ public XML getRunningConfig() throws SAXException, IOException { * * @return true if validation successful. * @throws java.io.IOException If there are errors communicating with the netconf server. - * @throws org.xml.sax.SAXException If there are errors parsing the XML reply. */ - public boolean validate() throws IOException, SAXException { + public boolean validate() throws IOException { String rpc = "" + "" + diff --git a/src/main/java/net/juniper/netconf/XML.java b/src/main/java/net/juniper/netconf/XML.java index b77ec30..1db7974 100644 --- a/src/main/java/net/juniper/netconf/XML.java +++ b/src/main/java/net/juniper/netconf/XML.java @@ -7,6 +7,7 @@ */ package net.juniper.netconf; +import java.util.logging.Logger; import com.google.common.base.Preconditions; @@ -47,9 +48,22 @@ */ public class XML { + private static final Logger logger = Logger.getLogger(XML.class.getName()); private final Element activeElement; private final Document ownerDoc; + /** + * Creates a new {@code XML} wrapper with the supplied DOM {@link Element} as its + * initial active element. + *

+ * This constructor is intentionally protected; normal clients are + * expected to obtain {@code XML} instances via {@link XMLBuilder} rather than + * constructing them directly. Keeping the constructor non‑public ensures the + * internal DOM is manipulated through the provided fluent API. + * + * @param active the DOM element that will serve as the current active node; + * must not be {@code null} + */ protected XML(Element active) { this.activeElement = active; ownerDoc = active.getOwnerDocument(); @@ -65,12 +79,14 @@ private String trim(String str) { return st; } + + /** * Get the owner Document for the XML object. * @return The org.w3c.dom.Document object for the XML */ public Document getOwnerDocument() { - return this.ownerDoc; + return (Document) ownerDoc.cloneNode(true); // deep copy } /** @@ -212,15 +228,21 @@ public void addSiblings(String element, String[] text) { * text value as the key value. */ public void addSiblings(Map map) { - List keyList = new ArrayList<>(map.keySet()); - activeElement.getParentNode(); - for (Object element : keyList) { - String elementName = (String) element; - String text = map.get(element); - Element newElement = ownerDoc.createElement(elementName); - Node textNode = ownerDoc.createTextNode(text); - newElement.appendChild(textNode); - activeElement.appendChild(newElement); } + if (map == null || map.isEmpty()) { + return; + } + + Node parent = activeElement.getParentNode(); // get the parent once + if (parent == null) { + throw new IllegalStateException( + "Cannot add siblings: active element has no parent"); + } + + for (Map.Entry entry : map.entrySet()) { + Element e = ownerDoc.createElement(entry.getKey()); + e.appendChild(ownerDoc.createTextNode(entry.getValue())); + parent.appendChild(e); // add to the *parent* + } } /** @@ -274,6 +296,15 @@ public void setText(String text) { } } + /** + * Sets the text content of the active XML element. + * + * @param text The text content to set on the current active element. + */ + public void setTextContent(String text) { + activeElement.setTextContent(text); + } + /** * Sets the attribute ("delete","delete") for the active element of XML * object. @@ -321,86 +352,89 @@ public void junosInsert(String before, String name) { } /** - * Find the text value of an element. + * Finds the text value of an element in the XML hierarchy specified by the list. + * * @param list - * The String based list of elements which determine the hierarchy. - * For example, for the below XML: - * <rpc-reply> - * <environment-information> + * A list of strings representing the XML path. Each entry corresponds to an XML tag. + * To apply a text filter, use "tag~text" syntax (e.g., "name~FPC 0 CPU"). + * For example, to extract <temperature> from: + * <rpc-reply> + * <environment-information> * <environment-item> - * <name>FPC 0 CPU</name> - * <temperature> - * To find out the text value of temperature node, the list - * should be- {"environment-information","environment-item", - * "name~FPC 0 CPU","temperature"} - * @return The text value of the element. + * <name>FPC 0 CPU</name> + * <temperature>55</temperature> + * the list should be: + * {"environment-information", "environment-item", "name~FPC 0 CPU", "temperature"} + * + * @return The trimmed text value of the target element, + * or {@code null} if the path is invalid, the list is empty/null, + * or if the element has no text content. */ public String findValue(List list) { + if (list == null || list.isEmpty()) { + return null; + } + Element nextElement = ownerDoc.getDocumentElement(); boolean nextElementFound; for (int k=0; k - * ge-1/0/0 - * - * .... - * In this case, the list passed to findValue function - * should contain (..,"physical-interface","name~ge-1/0/0", - * "logical-interface",..) - * This will fetch me the required element. + if (!nextElementName.contains("~")) { + NodeList nextElementList = + nextElement != null + ? nextElement.getElementsByTagName(nextElementName) + : null; + if (nextElementList == null || nextElementList.getLength() == 0) { + logger.fine("Element '" + nextElementName + "' not found in findValue()"); + return null; + } + + /* If the next‑to‑next (n2n) element is a filter based on + * text value, then do the required filtering. + */ + String n2nString = null; + if (k findNodes(List list) { nextElementFound = false; String nextElementName = list.get(k); if (!nextElementName.contains("~")) { - try { - NodeList nextElementList = nextElement. - getElementsByTagName(nextElementName); - /* If the next to next(n2n) element is a filter based on - * text value, - * then do the required filtering. - * For example, - * .... - * - * ge-1/0/0 - * - * .... - * In this case, the list passed to findValue function - * should contain (..,"physical-interface","name~ge-1/0/0", - * "logical-interface",..) - * This will fetch me the required element. + NodeList nextElementList = + nextElement != null + ? nextElement.getElementsByTagName(nextElementName) + : null; + if (nextElementList == null || nextElementList.getLength() == 0) { + logger.fine("Element '" + nextElementName + "' not found in findNodes()"); + return null; + } + /* If the next to next(n2n) element is a filter based on + * text value, + * then do the required filtering. + * For example, + * .... + * + * ge-1/0/0 + * + * .... + * In this case, the list passed to findValue function + * should contain (..,"physical-interface","name~ge-1/0/0", + * "logical-interface",..) + * This will fetch me the required element. + */ + String n2nString = null; + if (kXMLBuilder is used to create an XML object.This is useful to + * An XMLBuilder is used to create an XML object.This is useful to * create XML RPC's and configurations. *

* As an example, one @@ -26,25 +27,42 @@ * */ public class XMLBuilder { - + private DOMImplementation impl; private DocumentBuilder builder; - + /** Monotonic counter used to populate the mandatory {@code message-id} attribute on elements. */ + private static final AtomicLong MSG_ID_GEN = new AtomicLong(1); + /** + * Creates an empty <rpc> root element with the NETCONF base namespace + * and an auto‑incremented {@code message-id} attribute as required by + * RFC 6241 §4.1. + */ + private Document createRpcRoot() { + // Create in the NETCONF base namespace so the xmlns attribute is correct + Document doc = impl.createDocument( + "urn:ietf:params:xml:ns:netconf:base:1.0", + "rpc", + null); + Element root = doc.getDocumentElement(); + root.setAttribute("message-id", String.valueOf(MSG_ID_GEN.getAndIncrement())); + return doc; + } + /** * Prepares a new <code><XMLBuilder</code> object. * @throws ParserConfigurationException if there are issues parsing the configuration. */ public XMLBuilder() throws ParserConfigurationException { - DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance() ; + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance() ; builder = factory.newDocumentBuilder() ; impl = builder.getDOMImplementation(); } - + /** * Create a new configuration(single-level hierarchy) as an XML object. - *

+ *

Convenience method for creating a single‑level configuration.

* @param elementLevelOne - * The element at the top-most hierarchy. For example, to create a + * The element at the top-most hierarchy. For example, to create a * configuration, * "<configuration><system/></configuration>" the * String to be passed is "system". @@ -57,12 +75,12 @@ public XML createNewConfig(String elementLevelOne) { rootElement.appendChild(elementOne); return new XML(elementOne); } - + /** * Create a new configuration(two-level hierarchy) as an XML object. - *

+ *

Creates a configuration with two nested hierarchy levels.

* @param elementLevelOne - * The element at the top-most hierarchy. + * The element at the top-most hierarchy. * @param elementLevelTwo * The element at level-2 hierarchy. * @return XML object. @@ -79,9 +97,9 @@ public XML createNewConfig(String elementLevelOne, String elementLevelTwo) { /** * Create a new configuration(three-level hierarchy) as an XML object. - *

+ *

Creates a configuration with three nested hierarchy levels.

* @param elementLevelOne - * The element at the top-most hierarchy. + * The element at the top-most hierarchy. * @param elementLevelTwo * The element at level-2 hierarchy. * @param elementLevelThree @@ -100,12 +118,12 @@ public XML createNewConfig(String elementLevelOne, String elementLevelTwo, rootElement.appendChild(elementOne); return new XML(elementThree); } - + /** * Create a new configuration(four-level hierarchy) as an XML object. - *

+ *

Creates a configuration with four nested hierarchy levels.

* @param elementLevelOne - * The element at the top-most hierarchy. + * The element at the top-most hierarchy. * @param elementLevelTwo * The element at level-2 hierarchy. * @param elementLevelThree @@ -128,12 +146,12 @@ public XML createNewConfig(String elementLevelOne, String elementLevelTwo, rootElement.appendChild(elementOne); return new XML(elementFour); } - + /** * Create a new configuration as an XML object. - *

+ *

Creates a configuration with a variable-depth hierarchy defined by the supplied list.

* @param elementList - * The list of elements to be included in the XML. For example, + * The list of elements to be included in the XML. For example, * to create a configuration, * "<configuration><system><services><ftp/> * </services></system></configuration>" the @@ -141,7 +159,7 @@ public XML createNewConfig(String elementLevelOne, String elementLevelTwo, * @return XML object. */ public XML createNewConfig(List elementList) { - if (elementList.size() == 0) + if (elementList.isEmpty()) return null; Document doc = impl.createDocument(null, "configuration", null); Element rootElement = doc.getDocumentElement(); @@ -156,33 +174,33 @@ public XML createNewConfig(List elementList) { rootElement.appendChild(last); return new XML(elementLevelLast); } - + /** - * Create a new RPC(single-level hierarchy) as an XML object. - *

+ *

Convenience method for a single‑level RPC. The builder automatically sets the mandatory {@code message-id} attribute and the NETCONF base namespace.

* @param elementLevelOne - * The element at the top-most hierarchy. + * The element at the top-most hierarchy. * @return XML object. */ public XML createNewRPC(String elementLevelOne) { - Document doc = impl.createDocument(null, "rpc", null); + Document doc = createRpcRoot(); Element rootElement = doc.getDocumentElement(); Element elementOne = doc.createElement(elementLevelOne); rootElement.appendChild(elementOne); return new XML(elementOne); } - + /** * Create a new RPC(two-level hierarchy) as an XML object. - *

+ *

Creates an RPC with two nested hierarchy levels.

+ * The {@code message-id} attribute and base namespace are filled in automatically. * @param elementLevelOne - * The element at the top-most hierarchy. + * The element at the top-most hierarchy. * @param elementLevelTwo * The element at level-2 hierarchy. * @return XML object. */ public XML createNewRPC(String elementLevelOne, String elementLevelTwo) { - Document doc = impl.createDocument(null, "rpc", null); + Document doc = createRpcRoot(); Element rootElement = doc.getDocumentElement(); Element elementOne = doc.createElement(elementLevelOne); Element elementTwo = doc.createElement(elementLevelTwo); @@ -190,12 +208,13 @@ public XML createNewRPC(String elementLevelOne, String elementLevelTwo) { rootElement.appendChild(elementOne); return new XML(elementTwo); } - + /** * Create a new RPC(three-level hierarchy) as an XML object. - *

+ *

Creates an RPC with three nested hierarchy levels.

+ * The {@code message-id} attribute and base namespace are filled in automatically. * @param elementLevelOne - * The element at the top-most hierarchy. + * The element at the top-most hierarchy. * @param elementLevelTwo * The element at level-2 hierarchy. * @param elementLevelThree @@ -204,7 +223,7 @@ public XML createNewRPC(String elementLevelOne, String elementLevelTwo) { */ public XML createNewRPC(String elementLevelOne, String elementLevelTwo, String elementLevelThree) { - Document doc = impl.createDocument(null, "rpc", null); + Document doc = createRpcRoot(); Element rootElement = doc.getDocumentElement(); Element elementOne = doc.createElement(elementLevelOne); Element elementTwo = doc.createElement(elementLevelTwo); @@ -214,12 +233,13 @@ public XML createNewRPC(String elementLevelOne, String elementLevelTwo, rootElement.appendChild(elementOne); return new XML(elementThree); } - + /** * Create a new RPC(four-level hierarchy) as an XML object. - *

+ *

Creates an RPC with four nested hierarchy levels.

+ * The {@code message-id} attribute and base namespace are filled in automatically. * @param elementLevelOne - * The element at the top-most hierarchy. + * The element at the top-most hierarchy. * @param elementLevelTwo * The element at level-2 hierarchy. * @param elementLevelThree @@ -230,7 +250,7 @@ public XML createNewRPC(String elementLevelOne, String elementLevelTwo, */ public XML createNewRPC(String elementLevelOne, String elementLevelTwo, String elementLevelThree, String elementLevelFour) { - Document doc = impl.createDocument(null, "rpc", null); + Document doc = createRpcRoot(); Element rootElement = doc.getDocumentElement(); Element elementOne = doc.createElement(elementLevelOne); Element elementTwo = doc.createElement(elementLevelTwo); @@ -242,10 +262,11 @@ public XML createNewRPC(String elementLevelOne, String elementLevelTwo, rootElement.appendChild(elementOne); return new XML(elementFour); } - + /** * Create a new RPC as an XML object. - *

+ *

Creates an RPC with hierarchy defined by the supplied element list.

+ * The {@code message-id} attribute and base namespace are filled in automatically. * @param elementList * The list of elements to be included in the XML. For example, the * list {"get-interface-information","terse"} will create the RPC- @@ -254,9 +275,9 @@ public XML createNewRPC(String elementLevelOne, String elementLevelTwo, * @return XML object. */ public XML createNewRPC(List elementList) { - if (elementList.size() == 0) + if (elementList.isEmpty()) return null; - Document doc = impl.createDocument(null, "rpc", null); + Document doc = createRpcRoot(); Element rootElement = doc.getDocumentElement(); Element elementLevelLast = doc.createElement(elementList. get(elementList.size()-1)); @@ -269,12 +290,12 @@ public XML createNewRPC(List elementList) { rootElement.appendChild(last); return new XML(elementLevelLast); } - + /** * Create a new xml(one-level hierarchy) as an XML object. - *

+ *

Convenience method for a single‑level XML document.

* @param elementLevelOne - * The element at the top-most hierarchy. + * The element at the top-most hierarchy. * @return XML object. */ public XML createNewXML(String elementLevelOne) { @@ -282,12 +303,12 @@ public XML createNewXML(String elementLevelOne) { Element rootElement = doc.getDocumentElement(); return new XML(rootElement); } - + /** * Create a new xml(two-level hierarchy) as an XML object. - *

+ *

Creates an XML document with two nested hierarchy levels.

* @param elementLevelOne - * The element at the top-most hierarchy. + * The element at the top-most hierarchy. * @param elementLevelTwo * The element at level-2 hierarchy. * @return XML object. @@ -299,12 +320,12 @@ public XML createNewXML(String elementLevelOne, String elementLevelTwo) { rootElement.appendChild(elementTwo); return new XML(elementTwo); } - + /** * Create a new xml(three-level hierarchy) as an XML object. - *

+ *

Creates an XML document with three nested hierarchy levels.

* @param elementLevelOne - * The element at the top-most hierarchy. + * The element at the top-most hierarchy. * @param elementLevelTwo * The element at level-2 hierarchy. * @param elementLevelThree @@ -321,12 +342,12 @@ public XML createNewXML(String elementLevelOne, String elementLevelTwo, rootElement.appendChild(elementTwo); return new XML(elementThree); } - + /** * Create a new xml(four-level hierarchy) as an XML object. - *

+ *

Creates an XML document with four nested hierarchy levels.

* @param elementLevelOne - * The element at the top-most hierarchy. + * The element at the top-most hierarchy. * @param elementLevelTwo * The element at level-2 hierarchy. * @param elementLevelThree @@ -347,10 +368,10 @@ public XML createNewXML(String elementLevelOne, String elementLevelTwo, rootElement.appendChild(elementTwo); return new XML(elementFour); } - + /** * Create a new xml as an XML object. - *

+ *

Creates an XML document with hierarchy defined by the supplied element list.

* @param elementList * The list of elements to be included in the XML. For example, the * list {"firstElement","secondElement"} will create the xml- @@ -358,7 +379,7 @@ public XML createNewXML(String elementLevelOne, String elementLevelTwo, * @return XML object. */ public XML createNewXML(List elementList) { - if (elementList.size() == 0) + if (elementList.isEmpty()) return null; String elementLevelOne = elementList.get(0); Document doc = impl.createDocument(null, elementLevelOne, null); diff --git a/src/main/java/net/juniper/netconf/element/AbstractNetconfElement.java b/src/main/java/net/juniper/netconf/element/AbstractNetconfElement.java index 0148a37..51f7084 100644 --- a/src/main/java/net/juniper/netconf/element/AbstractNetconfElement.java +++ b/src/main/java/net/juniper/netconf/element/AbstractNetconfElement.java @@ -1,9 +1,5 @@ package net.juniper.netconf.element; -import lombok.EqualsAndHashCode; -import lombok.ToString; -import lombok.Value; -import lombok.experimental.NonFinal; import net.juniper.netconf.NetconfConstants; import org.w3c.dom.Document; import org.w3c.dom.Element; @@ -20,22 +16,67 @@ import static java.lang.String.format; -@Value -@NonFinal +/** + * Base class for all model objects that represent NETCONF XML fragments + * (e.g. {@code }, {@code }). + *

+ * Each subclass wraps a {@link org.w3c.dom.Document} so that: + *

    + *
  • The DOM is immutable from the caller’s perspective— + * getters return defensive copies.
  • + *
  • An on‑demand, pre‑rendered XML {@link String} is cached for fast + * {@link #equals(Object)}, {@link #hashCode()}, and logging.
  • + *
+ * Common XML helper methods live here so builders and parsers can share a + * single, RFC 6241‑aware implementation. + * + * @author Juniper Networks + */ public abstract class AbstractNetconfElement { - @ToString.Exclude - @EqualsAndHashCode.Exclude - Document document; - - @ToString.Exclude - String xml; + private final Document document; + private final String xml; + /** + * Wraps the supplied DOM {@link Document} and pre‑computes its XML string + * representation for fast equality checks and logging. + * + * @param document a fully‑formed NETCONF XML document; must not be {@code null} + * @throws NullPointerException if {@code document} is {@code null} + */ protected AbstractNetconfElement(final Document document) { this.document = document; this.xml = createXml(document); } + /** + * Returns a defensive deep copy of the underlying DOM + * {@link Document} so callers cannot mutate the internal state. + * + * @return a cloned {@link Document} representing this element + */ + public Document getDocument() { + return (Document) document.cloneNode(true); // deep copy + } + + /** + * Returns the cached XML string representation of the wrapped document. + * + * @return XML string with no declaration (UTF‑8 assumed) + */ + public String getXml() { + return xml; + } + + /** + * Creates an empty, namespace‑aware DOM {@link Document}. + *

+ * Internally delegates to {@link #createDocumentBuilderFactory()} to ensure + * all security features are applied consistently. + * + * @return a brand‑new, empty {@link Document} + * @throws IllegalStateException if the platform’s XML parser cannot be configured + */ protected static Document createBlankDocument() { try { return createDocumentBuilderFactory().newDocumentBuilder().newDocument(); @@ -44,12 +85,30 @@ protected static Document createBlankDocument() { } } + /** + * Returns a pre‑configured {@link DocumentBuilderFactory} with + * namespace awareness enabled. Additional hardening options + * (e.g. disallowing DTDs) can be added here centrally so every + * NETCONF element parser benefits. + * + * @return a namespace‑aware {@link DocumentBuilderFactory} + */ protected static DocumentBuilderFactory createDocumentBuilderFactory() { final DocumentBuilderFactory documentBuilderFactory = DocumentBuilderFactory.newInstance(); documentBuilderFactory.setNamespaceAware(true); return documentBuilderFactory; } + /** + * Serialises a DOM {@link Document} to its XML string representation. + *

+ * The XML declaration is omitted because NETCONF frames are always UTF‑8 + * and the declaration is not required on the wire. + * + * @param document the document to serialise; must not be {@code null} + * @return XML string (no declaration) + * @throws IllegalStateException if a {@link TransformerException} occurs + */ protected static String createXml(final Document document) { try { final TransformerFactory transformerFactory = TransformerFactory.newInstance(); @@ -63,10 +122,28 @@ protected static String createXml(final Document document) { } } + /** + * Convenience helper that builds an XPath expression matching a NETCONF + * element in the base 1.0 namespace with the specified local‑name. + * + * @param elementName local name (e.g. {@code "rpc-reply"}) + * @return an XPath string scoped to the NETCONF base namespace + */ protected static String getXpathFor(final String elementName) { return format("/*[namespace-uri()='urn:ietf:params:xml:ns:netconf:base:1.0' and local-name()='%s']", elementName); } + /** + * Appends a child element (with optional text content) to the given parent, + * using the NETCONF base 1.0 namespace and the provided prefix. + * + * @param document owner document + * @param parentElement element to which the new child is appended + * @param namespacePrefix namespace prefix to set on the new element + * @param elementName local name of the child element + * @param text text content; if {@code null} the element is skipped + * @return the newly created element, or {@code null} if {@code text} was {@code null} + */ protected static Element appendElementWithText( final Document document, final Element parentElement, @@ -85,6 +162,14 @@ protected static Element appendElementWithText( } } + /** + * Safely retrieves an attribute value from the supplied DOM {@link Element}. + * + * @param element the element to query; may be {@code null} + * @param attributeName the local name of the attribute + * @return the attribute value if the element is non‑null and the attribute + * exists; otherwise {@code null} + */ protected static String getAttribute(final Element element, final String attributeName) { if (element != null && element.hasAttribute(attributeName)) { return element.getAttribute(attributeName); @@ -93,6 +178,13 @@ protected static String getAttribute(final Element element, final String attribu } } + /** + * Returns the trimmed text content of a DOM {@link Element}. + * + * @param element the element whose {@code getTextContent()} should be read; + * may be {@code null} + * @return trimmed text or {@code null} if the element is {@code null} + */ protected static String getTextContent(final Element element) { if (element == null) { return null; @@ -101,7 +193,32 @@ protected static String getTextContent(final Element element) { } } + /** + * Convenience null‑safe {@link String#trim()} wrapper. + * + * @param string the input string; may be {@code null} + * @return a trimmed copy of {@code string}, or {@code null} if the input + * was {@code null} + */ protected static String trim(final String string) { return string == null ? null : string.trim(); } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + AbstractNetconfElement that = (AbstractNetconfElement) o; + return xml.equals(that.xml); + } + + @Override + public int hashCode() { + return xml.hashCode(); + } + + @Override + public String toString() { + return getClass().getSimpleName() + "{}"; + } } diff --git a/src/main/java/net/juniper/netconf/element/Datastore.java b/src/main/java/net/juniper/netconf/element/Datastore.java index 3042d68..35a5746 100644 --- a/src/main/java/net/juniper/netconf/element/Datastore.java +++ b/src/main/java/net/juniper/netconf/element/Datastore.java @@ -6,13 +6,60 @@ * Datastore *

* As defined by RFC-8342. - * See https://datatracker.ietf.org/doc/html/rfc8342#section-5 + * See ... */ public enum Datastore { - RUNNING, CANDIDATE, STARTUP, INTENDED, OPERATIONAL; + /** + * The running configuration datastore as defined by RFC-8342. + */ + RUNNING("running"), + /** + * The candidate configuration datastore as defined by RFC-8342. + */ + CANDIDATE("candidate"), + /** + * The startup configuration datastore as defined by RFC-8342. + */ + STARTUP("startup"), + /** + * The intended configuration datastore as defined by RFC-8342. + */ + INTENDED("intended"), + /** + * The operational state datastore as defined by RFC-8342. + */ + OPERATIONAL("operational"); + private final String xmlName; + + Datastore(String xmlName) { + this.xmlName = xmlName.toLowerCase(Locale.US); + } + + /** + * Returns the XML name (lowercase) for this datastore. + */ @Override public String toString() { - return this.name().toLowerCase(Locale.US); + return xmlName; + } + + /** + * Returns the Datastore enum constant corresponding to the given XML name (case-insensitive). + * @param name the XML name to lookup + * @return the Datastore enum constant + * @throws IllegalArgumentException if no matching constant exists + */ + public static Datastore fromXmlName(String name) { + if (name == null) { + throw new IllegalArgumentException("Datastore XML name cannot be null"); + } + String nameLc = name.toLowerCase(Locale.US); + for (Datastore ds : values()) { + if (ds.xmlName.equals(nameLc)) { + return ds; + } + } + throw new IllegalArgumentException("Unknown Datastore XML name: " + name); } } diff --git a/src/main/java/net/juniper/netconf/element/Hello.java b/src/main/java/net/juniper/netconf/element/Hello.java index 9395d95..2bf8927 100644 --- a/src/main/java/net/juniper/netconf/element/Hello.java +++ b/src/main/java/net/juniper/netconf/element/Hello.java @@ -1,11 +1,5 @@ package net.juniper.netconf.element; -import lombok.Builder; -import lombok.EqualsAndHashCode; -import lombok.Singular; -import lombok.ToString; -import lombok.Value; -import lombok.extern.slf4j.Slf4j; import net.juniper.netconf.NetconfConstants; import org.w3c.dom.Document; import org.w3c.dom.Element; @@ -21,29 +15,188 @@ import javax.xml.xpath.XPathFactory; import java.io.IOException; import java.io.StringReader; +import java.util.ArrayList; +import java.util.Collections; import java.util.List; +import java.util.Objects; +import java.util.logging.Logger; /** - * Class to represent a NETCONF hello element - https://datatracker.ietf.org/doc/html/rfc6241#section-8.1 + * Class to represent a NETCONF hello element - ... */ -@Slf4j -@Value -@ToString(callSuper = true) -@EqualsAndHashCode(callSuper = true) public class Hello extends AbstractNetconfElement { + private static final Logger log = Logger.getLogger(Hello.class.getName()); + + /** + * Validates that the supplied string is a syntactically correct URI. + * + * @param uri the capability string + * @throws IllegalArgumentException if the string is not a valid URI + */ + private static void assertValidUri(String uri) { + try { + new java.net.URI(uri); + } catch (java.net.URISyntaxException e) { + throw new IllegalArgumentException("Capability MUST be a valid URI per RFC 3986: " + uri, e); + } + } + private static final String XPATH_HELLO = getXpathFor("hello"); private static final String XPATH_HELLO_SESSION_ID = XPATH_HELLO + getXpathFor("session-id"); private static final String XPATH_HELLO_CAPABILITIES = XPATH_HELLO + getXpathFor("capabilities"); private static final String XPATH_HELLO_CAPABILITIES_CAPABILITY = XPATH_HELLO_CAPABILITIES + getXpathFor("capability"); - String sessionId; + private final String sessionId; + private final List capabilities; + + /** + * Constructs an immutable {@code Hello} element. + * + * @param namespacePrefix optional XML namespace prefix, may be {@code null} + * @param originalDocument original DOM document to wrap or {@code null} to build a new one + * @param sessionId session-id presented by the NETCONF peer (may be {@code null} for client hello) + * @param capabilities list of capability URIs; a defensive copy is taken + */ + public Hello(final String namespacePrefix, final Document originalDocument, final String sessionId, final List capabilities) { + super(getDocument(originalDocument, namespacePrefix, sessionId, capabilities)); + this.sessionId = sessionId; + this.capabilities = capabilities != null ? List.copyOf(capabilities) : Collections.emptyList(); + } + + /** + * Returns the NETCONF session-id conveyed in this <hello>. + * + * @return session-id or {@code null} if absent + */ + public String getSessionId() { + return sessionId; + } - @Singular("capability") - List capabilities; + /** + * Returns an immutable copy of the capability URIs advertised in this hello. + * + * @return list of capability strings (never {@code null}) + */ + public List getCapabilities() { + return new ArrayList<>(capabilities); + } + /** + * Checks whether the given capability is present in this hello. + * + * @param capability capability URI to test + * @return {@code true} if the capability list contains the URI + */ public boolean hasCapability(final String capability) { - return capabilities.contains(capability); + return capability != null && capabilities.contains(capability); + } + + /** + * Returns a new {@link Builder} for programmatically constructing a {@code Hello}. + * + * @return fresh {@link Builder} + */ + public static Builder builder() { + return new Builder(); + } + + /** + * Fluent builder for creating {@link Hello} objects. + */ + public static class Builder { + /** + * Creates an empty {@code Builder}. + */ + public Builder() { + } + private String sessionId; + private List capabilities = new java.util.ArrayList<>(); + private String namespacePrefix; + + /** + * Sets the {@code session-id} to embed in the hello. + * + * @param sessionId session identifier + * @return this {@code Builder} + */ + public Builder sessionId(String sessionId) { + this.sessionId = sessionId; + return this; + } + + /** + * Replaces the entire capability list with the supplied collection. + *

+ * A defensive copy is made, so subsequent modifications to the + * caller‑supplied {@code List} will not affect the builder. + * Each capability URI is validated against RFC 3986; an + * {@link IllegalArgumentException} is thrown if any entry is malformed. + * + * @param capabilities list of capability URIs, or {@code null} to clear + * the current list + * @return this {@code Builder} for fluent chaining + * @throws IllegalArgumentException if any capability is not a valid URI + */ + public Builder capabilities(List capabilities) { + if (capabilities != null) { + for (String cap : capabilities) { + assertValidUri(cap); + } + this.capabilities = new java.util.ArrayList<>(capabilities); // defensive copy + } + return this; + } + + /** + * Adds a single capability URI to the list. + * + * @param capability capability URI to add; ignored if {@code null} + * @return this {@code Builder} + */ + public Builder capability(String capability) { + if (capability != null) { + assertValidUri(capability); + this.capabilities.add(capability); + } + return this; + } + + /** + * Sets the XML namespace prefix to use when generating elements. + * + * @param namespacePrefix prefix string, e.g., "nc" + * @return this {@code Builder} + */ + public Builder namespacePrefix(String namespacePrefix) { + this.namespacePrefix = namespacePrefix; + return this; + } + + /** + * Builds a new immutable {@link Hello} instance using the configured values. + * + * @return constructed {@link Hello} + */ + public Hello build() { + // RFC 6241 § 8.1 — each peer MUST advertise at least the base 1.1 capability + final String BASE_11 = "urn:ietf:params:netconf:base:1.1"; + + List caps = capabilities == null + ? new java.util.ArrayList<>() + : new java.util.ArrayList<>(capabilities); + + if (!caps.contains(BASE_11)) { + caps.add(BASE_11); + } + + return new Hello( + namespacePrefix, // prefix (may be null) + null, // originalDocument (none when building) + sessionId, + caps + ); + } } /** @@ -59,44 +212,39 @@ public boolean hasCapability(final String capability) { public static Hello from(final String xml) throws ParserConfigurationException, IOException, SAXException, XPathExpressionException { + // RFC 6241 §3.2: Reject XML that contains a DOCTYPE declaration + if (xml.contains(" capabilities = new ArrayList<>(); + for (int i = 0; i < capabilitiesNodes.getLength(); i++) { + final Node node = capabilitiesNodes.item(i); + final String capability = node.getTextContent(); + assertValidUri(capability); + capabilities.add(capability); } - final Hello hello = builder.build(); - log.info("hello is: {}", hello.getXml()); + final Hello hello = new Hello( + null, // namespacePrefix + document, // originalDocument + sessionId, + capabilities + ); + log.info("hello is: " + hello.getXml()); return hello; } - @Builder - private Hello( - final Document originalDocument, - final String namespacePrefix, - final String sessionId, - @Singular("capability") final List capabilities) { - super(getDocument(originalDocument, namespacePrefix, sessionId, capabilities)); - this.sessionId = sessionId; - this.capabilities = capabilities; - } - private static Document getDocument( final Document originalDocument, final String namespacePrefix, final String sessionId, final List capabilities) { - if (originalDocument != null) { - return originalDocument; - } else { - return createDocument(namespacePrefix, sessionId, capabilities); - } + return Objects.requireNonNullElseGet(originalDocument, () -> createDocument(namespacePrefix, sessionId, capabilities)); } private static Document createDocument( @@ -114,13 +262,15 @@ private static Document createDocument( final Element capabilitiesElement = createdDocument.createElementNS(NetconfConstants.URN_XML_NS_NETCONF_BASE_1_0, "capabilities"); capabilitiesElement.setPrefix(namespacePrefix); - capabilities.forEach(capability -> { - final Element capabilityElement = - createdDocument.createElementNS(NetconfConstants.URN_XML_NS_NETCONF_BASE_1_0, "capability"); - capabilityElement.setTextContent(capability); - capabilityElement.setPrefix(namespacePrefix); - capabilitiesElement.appendChild(capabilityElement); - }); + if (capabilities != null) { + for (String capability : capabilities) { + final Element capabilityElement = + createdDocument.createElementNS(NetconfConstants.URN_XML_NS_NETCONF_BASE_1_0, "capability"); + capabilityElement.setTextContent(capability); + capabilityElement.setPrefix(namespacePrefix); + capabilitiesElement.appendChild(capabilityElement); + } + } helloElement.appendChild(capabilitiesElement); if (sessionId != null) { @@ -133,4 +283,25 @@ private static Document createDocument( return createdDocument; } + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof Hello hello)) return false; + if (!super.equals(o)) return false; + return Objects.equals(sessionId, hello.sessionId) && + Objects.equals(capabilities, hello.capabilities); + } + + @Override + public int hashCode() { + return Objects.hash(super.hashCode(), sessionId, capabilities); + } + + @Override + public String toString() { + return "Hello{" + + "sessionId='" + sessionId + '\'' + + ", capabilities=" + capabilities + + "} " + super.toString(); + } } diff --git a/src/main/java/net/juniper/netconf/element/RpcError.java b/src/main/java/net/juniper/netconf/element/RpcError.java index 81f9ebf..c649fbd 100644 --- a/src/main/java/net/juniper/netconf/element/RpcError.java +++ b/src/main/java/net/juniper/netconf/element/RpcError.java @@ -1,34 +1,85 @@ package net.juniper.netconf.element; -import lombok.AccessLevel; -import lombok.Builder; -import lombok.Getter; -import lombok.RequiredArgsConstructor; -import lombok.Value; +import java.util.Objects; /** - * Class to represent a NETCONF rpc-error element - https://datatracker.ietf.org/doc/html/rfc6241#section-4.3 + * Represents a NETCONF RPC error with structured details as per RFC standards. + * + * @param errorType The type of error (e.g., transport, rpc, protocol, application). + * @param errorTag The error tag indicating the nature of the error (e.g., unknown-element, bad-attribute). + * @param errorSeverity The severity of the error (e.g., error or warning). + * @param errorPath The path to the node where the error occurred. + * @param errorMessage The human-readable message describing the error. + * @param errorMessageLanguage The language of the error message. + * @param errorInfo Additional structured error information. */ -@Value -@Builder -@RequiredArgsConstructor(access = AccessLevel.PRIVATE) -public class RpcError { - - ErrorType errorType; - ErrorTag errorTag; - ErrorSeverity errorSeverity; - String errorPath; - String errorMessage; - String errorMessageLanguage; - RpcErrorInfo errorInfo; - - @Getter - @RequiredArgsConstructor +public record RpcError(net.juniper.netconf.element.RpcError.ErrorType errorType, + net.juniper.netconf.element.RpcError.ErrorTag errorTag, + net.juniper.netconf.element.RpcError.ErrorSeverity errorSeverity, String errorPath, + String errorMessage, String errorMessageLanguage, + net.juniper.netconf.element.RpcError.RpcErrorInfo errorInfo) { + + @Override + public String toString() { + return "RpcError{" + + "errorType=" + errorType + + ", errorTag=" + errorTag + + ", errorSeverity=" + errorSeverity + + ", errorPath='" + errorPath + '\'' + + ", errorMessage='" + errorMessage + '\'' + + ", errorMessageLanguage='" + errorMessageLanguage + '\'' + + ", errorInfo=" + errorInfo + + '}'; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof RpcError)) return false; + RpcError rpcError = (RpcError) o; + return errorType == rpcError.errorType && + errorTag == rpcError.errorTag && + errorSeverity == rpcError.errorSeverity && + Objects.equals(errorPath, rpcError.errorPath) && + Objects.equals(errorMessage, rpcError.errorMessage) && + Objects.equals(errorMessageLanguage, rpcError.errorMessageLanguage) && + Objects.equals(errorInfo, rpcError.errorInfo); + } + + /** + * Enum representing the type of NETCONF error. + */ public enum ErrorType { - TRANSPORT("transport"), RPC("rpc"), PROTOCOL("protocol"), APPLICATION("application"); + /** Transport layer error. */ + TRANSPORT("transport"), + /** Error occurred in the NETCONF RPC layer. */ + RPC("rpc"), + /** Protocol layer error. */ + PROTOCOL("protocol"), + /** Application layer error. */ + APPLICATION("application"); private final String textContent; + ErrorType(String textContent) { + this.textContent = textContent; + } + + /** + * Returns the string representation of the error type. + * + * @return the text content of the error type + */ + public String getTextContent() { + return textContent; + } + + /** + * Converts a string to the corresponding ErrorType enum. + * + * @param textContent the string representation of the error type + * @return the corresponding ErrorType, or null if no match is found + */ public static ErrorType from(final String textContent) { for (final ErrorType errorType : ErrorType.values()) { if (errorType.textContent.equals(textContent)) { @@ -39,30 +90,68 @@ public static ErrorType from(final String textContent) { } } - @Getter - @RequiredArgsConstructor + /** + * Enum representing the tag categorizing the NETCONF error. + */ public enum ErrorTag { + /** The request or response references an in-use resource. */ IN_USE("in-use"), + /** A value in the request is not valid. */ INVALID_VALUE("invalid-value"), + /** The request is too large to be processed. */ TOO_BIG("too-big"), + /** A required attribute is missing from the request. */ MISSING_ATTRIBUTE("missing-attribute"), + /** An attribute value is not correct. */ BAD_ATTRIBUTE("bad-attribute"), + /** An unknown or unexpected attribute is present. */ UNKNOWN_ATTRIBUTE("unknown-attribute"), + /** A required element is missing from the request. */ MISSING_ELEMENT("missing-element"), + /** An element's value is invalid or unexpected. */ BAD_ELEMENT("bad-element"), + /** An unknown or unexpected element is present. */ UNKNOWN_ELEMENT("unknown-element"), + /** The specified namespace is not recognized. */ UNKNOWN_NAMESPACE("unknown-namespace"), + /** The request was denied due to insufficient access rights. */ ACCESS_DENIED("access-denied"), + /** The requested lock could not be obtained. */ LOCK_DENIED("lock-denied"), + /** The data item already exists and cannot be created again. */ DATA_EXISTS("data-exists"), + /** The requested data item does not exist. */ DATA_MISSING("data-missing"), + /** The operation is not supported by the server or resource. */ OPERATION_NOT_SUPPORTED("operation-not-supported"), + /** The operation failed for an unspecified reason. */ OPERATION_FAILED("operation-failed"), + /** The operation was only partially completed. */ PARTIAL_OPERATION("partial-operation"), + /** The message is not well-formed or violates syntax rules. */ MALFORMED_MESSAGE("malformed-message"); private final String textContent; + ErrorTag(String textContent) { + this.textContent = textContent; + } + + /** + * Returns the string representation of the error tag. + * + * @return the text content of the error tag + */ + public String getTextContent() { + return textContent; + } + + /** + * Converts a string to the corresponding ErrorTag enum. + * + * @param textContent the string representation of the error tag + * @return the corresponding ErrorTag, or null if no match is found + */ public static ErrorTag from(final String textContent) { for (final ErrorTag errorTag : ErrorTag.values()) { if (errorTag.textContent.equals(textContent)) { @@ -73,13 +162,36 @@ public static ErrorTag from(final String textContent) { } } - @Getter - @RequiredArgsConstructor + /** + * Enum representing the severity level of the NETCONF error. + */ public enum ErrorSeverity { - ERROR("error"), WARNING("warning"); + /** An error that causes the operation to fail. */ + ERROR("error"), + /** A warning that does not stop the operation. */ + WARNING("warning"); private final String textContent; + ErrorSeverity(String textContent) { + this.textContent = textContent; + } + + /** + * Returns the string representation of the error severity. + * + * @return the text content of the error severity + */ + public String getTextContent() { + return textContent; + } + + /** + * Converts a string to the corresponding ErrorSeverity enum. + * + * @param textContent the string representation of the error severity + * @return the corresponding ErrorSeverity, or null if no match is found + */ public static ErrorSeverity from(final String textContent) { for (final ErrorSeverity errorSeverity : ErrorSeverity.values()) { if (errorSeverity.textContent.equals(textContent)) { @@ -91,20 +203,366 @@ public static ErrorSeverity from(final String textContent) { } /** - * Class to represent a NETCONF rpc error-info element - https://datatracker.ietf.org/doc/html/rfc6241#section-4.3 + * Represents detailed NETCONF error information contained within the rpc-error's error-info element. */ - @Value - @Builder - @RequiredArgsConstructor(access = AccessLevel.PRIVATE) public static class RpcErrorInfo { - String badAttribute; - String badElement; - String badNamespace; - String sessionId; - String okElement; - String errElement; - String noOpElement; + private final String badAttribute; + private final String badElement; + private final String badNamespace; + private final String sessionId; + private final String okElement; + private final String errElement; + private final String noOpElement; + /** + * Constructs a new RpcErrorInfo instance with detailed fields. + * + * @param badAttribute the name of the attribute that caused the error + * @param badElement the name of the element that caused the error + * @param badNamespace the namespace involved in the error + * @param sessionId the session ID where the error occurred + * @param okElement the XML element indicating successful operation + * @param errElement the XML element indicating error operation + * @param noOpElement the XML element indicating no-operation + */ + public RpcErrorInfo(String badAttribute, String badElement, String badNamespace, String sessionId, String okElement, String errElement, String noOpElement) { + this.badAttribute = badAttribute; + this.badElement = badElement; + this.badNamespace = badNamespace; + this.sessionId = sessionId; + this.okElement = okElement; + this.errElement = errElement; + this.noOpElement = noOpElement; + } + + /** + * Returns the bad attribute associated with the error. + * + * @return the bad attribute string + */ + public String getBadAttribute() { + return badAttribute; + } + + /** + * Returns the bad element associated with the error. + * + * @return the bad element string + */ + public String getBadElement() { + return badElement; + } + + /** + * Returns the bad namespace associated with the error. + * + * @return the bad namespace string + */ + public String getBadNamespace() { + return badNamespace; + } + + /** + * Returns the session ID related to the error. + * + * @return the session ID string + */ + public String getSessionId() { + return sessionId; + } + + /** + * Returns the ok element related to the error. + * + * @return the ok element string + */ + public String getOkElement() { + return okElement; + } + + /** + * Returns the error element related to the error. + * + * @return the error element string + */ + public String getErrElement() { + return errElement; + } + + /** + * Returns the no-op element related to the error. + * + * @return the no-op element string + */ + public String getNoOpElement() { + return noOpElement; + } + + @Override + public String toString() { + return "RpcErrorInfo{" + + "badAttribute='" + badAttribute + '\'' + + ", badElement='" + badElement + '\'' + + ", badNamespace='" + badNamespace + '\'' + + ", sessionId='" + sessionId + '\'' + + ", okElement='" + okElement + '\'' + + ", errElement='" + errElement + '\'' + + ", noOpElement='" + noOpElement + '\'' + + '}'; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof RpcErrorInfo)) return false; + RpcErrorInfo that = (RpcErrorInfo) o; + return Objects.equals(badAttribute, that.badAttribute) && + Objects.equals(badElement, that.badElement) && + Objects.equals(badNamespace, that.badNamespace) && + Objects.equals(sessionId, that.sessionId) && + Objects.equals(okElement, that.okElement) && + Objects.equals(errElement, that.errElement) && + Objects.equals(noOpElement, that.noOpElement); + } + + @Override + public int hashCode() { + return Objects.hash(badAttribute, badElement, badNamespace, sessionId, okElement, errElement, noOpElement); + } + + /** + * Returns a new builder for constructing RpcErrorInfo instances. + * + * @return a new RpcErrorInfo.Builder instance + */ + public static Builder builder() { + return new Builder(); + } + + /** + * Fluent builder for constructing immutable {@link RpcErrorInfo} instances. + *

+ * Call the setter‑style methods to populate fields, then invoke {@link #build()} + * to obtain a ready‑to‑use object. The builder is not thread‑safe. + */ + public static class Builder { + private String badAttribute; + private String badElement; + private String badNamespace; + private String sessionId; + private String okElement; + private String errElement; + private String noOpElement; + + /** + * Creates a new builder instance with all fields initialized to null. + */ + public Builder() { + // Default constructor - all fields start as null + } + + /** + * Sets the name of the attribute that caused the error. + * + * @param badAttribute the attribute name + * @return this {@code Builder} for chaining + */ + public Builder badAttribute(String badAttribute) { + this.badAttribute = badAttribute; + return this; + } + + /** + * Sets the name of the element that caused the error. + * + * @param badElement the element name + * @return this {@code Builder} + */ + public Builder badElement(String badElement) { + this.badElement = badElement; + return this; + } + + /** + * Sets the namespace involved in the error. + * + * @param badNamespace the namespace URI + * @return this {@code Builder} + */ + public Builder badNamespace(String badNamespace) { + this.badNamespace = badNamespace; + return this; + } + + /** + * Sets the NETCONF session ID related to the error. + * + * @param sessionId the session ID + * @return this {@code Builder} + */ + public Builder sessionId(String sessionId) { + this.sessionId = sessionId; + return this; + } + + /** + * Sets the <ok-element> string. + * + * @param okElement element indicating success + * @return this {@code Builder} + */ + public Builder okElement(String okElement) { + this.okElement = okElement; + return this; + } + + /** + * Sets the <err-element> string. + * + * @param errElement element indicating error + * @return this {@code Builder} + */ + public Builder errElement(String errElement) { + this.errElement = errElement; + return this; + } + + /** + * Sets the <noop-element> string. + * + * @param noOpElement element indicating no‑operation + * @return this {@code Builder} + */ + public Builder noOpElement(String noOpElement) { + this.noOpElement = noOpElement; + return this; + } + + /** + * Builds the corresponding RpcErrorInfo instance. + * + * @return a new RpcErrorInfo object + */ + public RpcErrorInfo build() { + return new RpcErrorInfo(badAttribute, badElement, badNamespace, sessionId, okElement, errElement, noOpElement); + } + } + } + + /** + * Builder for assembling {@link RpcError} objects with optional fields. + *

+ * Populate the desired attributes via the fluent setters and finish with + * {@link #build()} to create an immutable {@code RpcError}. + */ + public static class Builder { + /** + * Creates an empty {@code Builder} instance. + */ + public Builder() { + } + private ErrorType errorType; + private ErrorTag errorTag; + private ErrorSeverity errorSeverity; + private String errorPath; + private String errorMessage; + private String errorMessageLanguage; + private RpcErrorInfo errorInfo; + + /** + * Sets the NETCONF error type. + * + * @param errorType the error type + * @return this {@code Builder} + */ + public Builder errorType(ErrorType errorType) { + this.errorType = errorType; + return this; + } + + /** + * Sets the NETCONF error tag. + * + * @param errorTag the error tag + * @return this {@code Builder} + */ + public Builder errorTag(ErrorTag errorTag) { + this.errorTag = errorTag; + return this; + } + + /** + * Sets the severity of the error. + * + * @param errorSeverity severity level + * @return this {@code Builder} + */ + public Builder errorSeverity(ErrorSeverity errorSeverity) { + this.errorSeverity = errorSeverity; + return this; + } + + /** + * Sets the XPath path to the node where the error occurred. + * + * @param errorPath NETCONF error-path value + * @return this {@code Builder} + */ + public Builder errorPath(String errorPath) { + this.errorPath = errorPath; + return this; + } + + /** + * Sets the human‑readable error message. + * + * @param errorMessage descriptive message + * @return this {@code Builder} + */ + public Builder errorMessage(String errorMessage) { + this.errorMessage = errorMessage; + return this; + } + + /** + * Sets the language tag of the error message (e.g., "en"). + * + * @param errorMessageLanguage IETF language tag + * @return this {@code Builder} + */ + public Builder errorMessageLanguage(String errorMessageLanguage) { + this.errorMessageLanguage = errorMessageLanguage; + return this; + } + + /** + * Attaches structured {@link RpcErrorInfo} details to the error. + * + * @param errorInfo additional structured info + * @return this {@code Builder} + */ + public Builder errorInfo(RpcErrorInfo errorInfo) { + this.errorInfo = errorInfo; + return this; + } + + /** + * Builds the corresponding RpcError instance. + * + * @return a new RpcError object + */ + public RpcError build() { + return new RpcError(errorType, errorTag, errorSeverity, errorPath, errorMessage, errorMessageLanguage, errorInfo); + } + } + + /** + * Returns a new builder for constructing RpcError instances. + * + * @return a new RpcError.Builder instance + */ + public static Builder builder() { + return new Builder(); } } diff --git a/src/main/java/net/juniper/netconf/element/RpcReply.java b/src/main/java/net/juniper/netconf/element/RpcReply.java index 9d35543..bf15051 100644 --- a/src/main/java/net/juniper/netconf/element/RpcReply.java +++ b/src/main/java/net/juniper/netconf/element/RpcReply.java @@ -1,12 +1,5 @@ package net.juniper.netconf.element; -import lombok.Builder; -import lombok.EqualsAndHashCode; -import lombok.Singular; -import lombok.ToString; -import lombok.Value; -import lombok.experimental.NonFinal; -import lombok.extern.slf4j.Slf4j; import net.juniper.netconf.NetconfConstants; import org.w3c.dom.Document; import org.w3c.dom.Element; @@ -23,58 +16,325 @@ import java.io.StringReader; import java.util.ArrayList; import java.util.List; +import java.util.Objects; import static java.util.Optional.ofNullable; /** - * Class to represent a NETCONF rpc-reply element - https://datatracker.ietf.org/doc/html/rfc6241#section-4.2 + * Represents a NETCONF rpc-reply element, including its message ID, status (ok or error), + * any errors returned, session ID, and capabilities. + * Based on RFC 6241 section 4.2. */ -@Slf4j -@Value -@NonFinal -@ToString(callSuper = true) -@EqualsAndHashCode(callSuper = true) public class RpcReply extends AbstractNetconfElement { + /** XPath for the root <rpc-reply> element. */ protected static final String XPATH_RPC_REPLY = getXpathFor("rpc-reply"); + + /** XPath for the <ok> element inside an <rpc-reply>. */ private static final String XPATH_RPC_REPLY_OK = XPATH_RPC_REPLY + getXpathFor("ok"); + + /** XPath for any <rpc-error> elements inside the reply. */ private static final String XPATH_RPC_REPLY_ERROR = XPATH_RPC_REPLY + getXpathFor("rpc-error"); + + /** XPath for the <error-type> child of an <rpc-error>. */ private static final String XPATH_RPC_REPLY_ERROR_TYPE = getXpathFor("error-type"); + + /** XPath for the <error-tag> child of an <rpc-error>. */ private static final String XPATH_RPC_REPLY_ERROR_TAG = getXpathFor("error-tag"); + + /** XPath for the <error-severity> child of an <rpc-error>. */ private static final String XPATH_RPC_REPLY_ERROR_SEVERITY = getXpathFor("error-severity"); + + /** XPath for the <error-path> child of an <rpc-error>. */ private static final String XPATH_RPC_REPLY_ERROR_PATH = getXpathFor("error-path"); + + /** XPath for the <error-message> child of an <rpc-error>. */ private static final String XPATH_RPC_REPLY_ERROR_MESSAGE = getXpathFor("error-message"); + + /** XPath for the <error-info> section inside an <rpc-error>. */ private static final String XPATH_RPC_REPLY_ERROR_INFO = getXpathFor("error-info"); + + /** XPath for the <bad-attribute> field within <error-info>. */ private static final String XPATH_RPC_REPLY_ERROR_INFO_BAD_ATTRIBUTE = XPATH_RPC_REPLY_ERROR_INFO + getXpathFor("bad-attribute"); + + /** XPath for the <bad-element> field within <error-info>. */ private static final String XPATH_RPC_REPLY_ERROR_INFO_BAD_ELEMENT = XPATH_RPC_REPLY_ERROR_INFO + getXpathFor("bad-element"); + + /** XPath for the <bad-namespace> field within <error-info>. */ private static final String XPATH_RPC_REPLY_ERROR_INFO_BAD_NAMESPACE = XPATH_RPC_REPLY_ERROR_INFO + getXpathFor("bad-namespace"); + + /** XPath for the <session-id> field within <error-info>. */ private static final String XPATH_RPC_REPLY_ERROR_INFO_SESSION_ID = XPATH_RPC_REPLY_ERROR_INFO + getXpathFor("session-id"); + + /** XPath for the <ok-element> field within <error-info>. */ private static final String XPATH_RPC_REPLY_ERROR_INFO_OK_ELEMENT = XPATH_RPC_REPLY_ERROR_INFO + getXpathFor("ok-element"); + + /** XPath for the <err-element> field within <error-info>. */ private static final String XPATH_RPC_REPLY_ERROR_INFO_ERR_ELEMENT = XPATH_RPC_REPLY_ERROR_INFO + getXpathFor("err-element"); + + /** XPath for the <noop-element> field within <error-info>. */ private static final String XPATH_RPC_REPLY_ERROR_INFO_NO_OP_ELEMENT = XPATH_RPC_REPLY_ERROR_INFO + getXpathFor("noop-element"); - String messageId; - boolean ok; - List errors; + private final String messageId; + private final boolean ok; + private final List errors; + /** Optional prefix (e.g. "nc") to apply to elements when we generate XML. */ + private final String namespacePrefix; + + private final String sessionId; + private final List capabilities; + + /* --------------------------------------------------------------------- + * Builder + * ------------------------------------------------------------------- */ + + /** + * Creates and returns a new {@link Builder} for constructing {@code RpcReply} instances. + *

+ * The builder pattern allows callers to set only the fields relevant to their use‑case + * and then call {@link Builder#build()} to obtain an immutable {@code RpcReply}. + * + * @return a fresh {@code Builder} instance + */ + public static Builder builder() { + return new Builder(); + } + + /** + * Fluent builder for creating immutable {@link RpcReply} instances. + *

+ * Configure the desired fields with the provided setter‑style methods + * and finish by calling {@link #build()} to obtain a fully‑constructed + * {@code RpcReply}. The builder is not thread‑safe; use a separate + * instance per construction sequence. + */ + public static final class Builder { + private Document originalDocument; + private String namespacePrefix; + private String messageId; + private boolean ok; + private List errors = new ArrayList<>(); + private String sessionId; + private List capabilities = new ArrayList<>(); + + private Builder() { } + + /** + * Sets the original {@link Document} this reply was parsed from. + * + * @param originalDocument the source DOM {@link Document}; may be {@code null} + * @return this {@code Builder} instance for method chaining + */ + public Builder originalDocument(Document originalDocument) { + this.originalDocument = originalDocument; + return this; + } + + /** + * Sets the XML namespace prefix to apply when generating new XML. + * + * @param namespacePrefix optional namespace prefix (e.g. "nc"); may be {@code null} + * @return this {@code Builder} for chaining + */ + public Builder namespacePrefix(String namespacePrefix) { + this.namespacePrefix = namespacePrefix; + return this; + } + + /** + * Sets the NETCONF message-id attribute for the reply. + * + * @param messageId the message ID string + * @return this {@code Builder} + */ + public Builder messageId(String messageId) { + this.messageId = messageId; + return this; + } + + /** + * Indicates whether the reply contains an <ok/> element. + * + * @param ok {@code true} if the operation succeeded + * @return this {@code Builder} + */ + public Builder ok(boolean ok) { + this.ok = ok; + return this; + } + + /** + * Replaces the current list of errors with the provided list. + * + * @param errors list of {@link RpcError}; if {@code null} an empty list is used + * @return this {@code Builder} + */ + public Builder errors(List errors) { + this.errors = errors != null ? errors : new ArrayList<>(); + return this; + } + + /** + * Adds a single {@link RpcError} to the reply. + * + * @param error the error to add; ignored if {@code null} + * @return this {@code Builder} + */ + public Builder addError(RpcError error) { + if (error != null) { + this.errors.add(error); + } + return this; + } + + /** + * Sets the NETCONF session-id associated with the reply. + * + * @param sessionId the session ID + * @return this {@code Builder} + */ + public Builder sessionId(String sessionId) { + this.sessionId = sessionId; + return this; + } + + /** + * Replaces the current capability list. + * + * @param capabilities list of capability URIs; if {@code null} an empty list is used + * @return this {@code Builder} + */ + public Builder capabilities(List capabilities) { + this.capabilities = capabilities != null ? capabilities : new ArrayList<>(); + return this; + } + + /** + * Adds a single NETCONF capability URI to the list. + * + * @param capability capability URI to add; ignored if {@code null} + * @return this {@code Builder} + */ + public Builder addCapability(String capability) { + if (capability != null) { + this.capabilities.add(capability); + } + return this; + } + + /** + * Builds an immutable {@link RpcReply} instance using the currently configured parameters. + * + * @return a new {@link RpcReply} + */ + public RpcReply build() { + return new RpcReply( + namespacePrefix, + originalDocument, + messageId, + ok, + errors, + sessionId, + capabilities + ); + } + } + + /** + * Returns the message ID associated with this rpc-reply. + * + * @return the message ID + */ + public String getMessageId() { + return messageId; + } + + /** + * Returns true if the rpc-reply indicates success (i.e., contains an <ok/> element). + * + * @return true if the reply is OK, false otherwise + */ + public boolean isOK() { + return ok; + } + /** + * Returns the list of errors contained in this rpc-reply. + * + * @return list of RpcError + */ + public List getErrors() { + return new ArrayList<>(errors); + } + + /** + * Returns true if the rpc-reply contains any errors or warnings. + * + * @return true if errors or warnings are present + */ public boolean hasErrorsOrWarnings() { return !errors.isEmpty(); } + /** + * Returns true if any of the errors in the rpc-reply are of severity "error". + * + * @return true if there are error-severity issues + */ public boolean hasErrors() { - return errors.stream().anyMatch(error -> error.getErrorSeverity() == RpcError.ErrorSeverity.ERROR); + return errors.stream().anyMatch(error -> error.errorSeverity() == RpcError.ErrorSeverity.ERROR); } + /** + * Returns true if any of the errors in the rpc-reply are of severity "warning". + * + * @return true if there are warning-severity issues + */ public boolean hasWarnings() { - return errors.stream().anyMatch(error -> error.getErrorSeverity() == RpcError.ErrorSeverity.WARNING); + return errors.stream().anyMatch(error -> error.errorSeverity() == RpcError.ErrorSeverity.WARNING); } + /** + * Returns the session ID associated with this rpc-reply. + * + * @return the session ID, or null if not present + */ + public String getSessionId() { + return sessionId; + } + + /** + * Returns the list of NETCONF capabilities reported in the rpc-reply. + * + * @return list of capability URIs + */ + public List getCapabilities() { + return new ArrayList<>(capabilities); + } + + /** Removes the RFC 6242 ']]>]]>' delimiter from the tail of a frame. */ + private static String stripEomDelimiter(String xml) { + return xml.replaceFirst("\\Q]]>]]>\\E\\s*$", ""); + } + + /** + * Parses the given NETCONF XML string into an {@code RpcReply} (or subtype). + * + * @param the concrete subtype of {@link AbstractNetconfElement} to return + * @param xml the NETCONF XML string to parse + * @return an {@code RpcReply} (or subclass) instance + * @throws ParserConfigurationException if a parser cannot be configured + * @throws IOException if the input cannot be read + * @throws SAXException if the XML is not well-formed + * @throws XPathExpressionException if the XPath lookup fails + */ @SuppressWarnings("unchecked") public static T from(final String xml) throws ParserConfigurationException, IOException, SAXException, XPathExpressionException { + String cleaned = stripEomDelimiter(xml); final Document document = createDocumentBuilderFactory().newDocumentBuilder() - .parse(new InputSource(new StringReader(xml))); + .parse(new InputSource(new StringReader(cleaned))); final XPath xPath = XPathFactory.newInstance().newXPath(); final Element loadConfigResultsElement = (Element) xPath.evaluate(RpcReplyLoadConfigResults.XPATH_RPC_REPLY_LOAD_CONFIG_RESULT, document, XPathConstants.NODE); @@ -86,16 +346,60 @@ public static T from(final String xml) final Element rpcReplyOkElement = (Element) xPath.evaluate(XPATH_RPC_REPLY_OK, document, XPathConstants.NODE); final List errorList = getRpcErrors(document, xPath, XPATH_RPC_REPLY_ERROR); - final RpcReply rpcReply = RpcReply.builder() - .messageId(getAttribute(rpcReplyElement, "message-id")) - .ok(rpcReplyOkElement != null) - .errors(errorList) - .originalDocument(document) - .build(); - log.info("rpc-reply is: {}", rpcReply.getXml()); + final RpcReply rpcReply = new RpcReply( + null, // no explicit prefix in parsed XML + document, + getAttribute(rpcReplyElement, "message-id"), + rpcReplyOkElement != null, + errorList, + extractSessionId(document, xPath), + extractCapabilities(document, xPath) + ); return (T) rpcReply; } + /** + * Extracts the session ID from an rpc-reply XML document. + * + * @param document the parsed XML document + * @param xPath the XPath instance to use + * @return the session ID, or null if not found + * @throws XPathExpressionException if the XPath lookup fails + */ + private static String extractSessionId(Document document, XPath xPath) throws XPathExpressionException { + Element sessionIdElement = (Element) xPath.evaluate(XPATH_RPC_REPLY_ERROR_INFO_SESSION_ID, document, XPathConstants.NODE); + if (sessionIdElement != null) { + return getTextContent(sessionIdElement); + } + return null; + } + + /** + * Extracts the list of capabilities from an rpc-reply XML document. + * + * @param document the parsed XML document + * @param xPath the XPath instance to use + * @return list of capability URIs + * @throws XPathExpressionException if the XPath lookup fails + */ + private static List extractCapabilities(Document document, XPath xPath) throws XPathExpressionException { + List caps = new ArrayList<>(); + NodeList capNodes = (NodeList) xPath.evaluate("//capability", document, XPathConstants.NODESET); + for (int i = 0; i < capNodes.getLength(); i++) { + caps.add(capNodes.item(i).getTextContent()); + } + return caps; + } + + /** + * Extracts all <rpc-error> elements from the XML document. + * + * @param document the parsed XML document + * @param xPath the XPath instance to use + * @param xpathQuery the XPath expression for locating <rpc-error> elements + * @return a list of RpcError objects + * @throws XPathExpressionException if any XPath lookup fails + */ protected static List getRpcErrors(final Document document, final XPath xPath, final String xpathQuery) throws XPathExpressionException { final NodeList errors = (NodeList) xPath.evaluate(xpathQuery, document, XPathConstants.NODESET); @@ -107,7 +411,7 @@ protected static List getRpcErrors(final Document document, final XPat final String errorSeverity = xPath.evaluate(expressionPrefix + XPATH_RPC_REPLY_ERROR_SEVERITY, document); final Element errorMessageElement = (Element) xPath.evaluate(expressionPrefix + XPATH_RPC_REPLY_ERROR_MESSAGE, document, XPathConstants.NODE); final Element errorPathElement = (Element) xPath.evaluate(expressionPrefix + XPATH_RPC_REPLY_ERROR_PATH, document, XPathConstants.NODE); - final RpcError.RpcErrorBuilder errorBuilder = RpcError.builder() + final RpcError.Builder errorBuilder = RpcError.builder() .errorType(RpcError.ErrorType.from(errorType)) .errorTag(RpcError.ErrorTag.from(errorTag)) .errorSeverity(RpcError.ErrorSeverity.from(errorSeverity)) @@ -141,32 +445,63 @@ protected static List getRpcErrors(final Document document, final XPat return errorList; } - @Builder - protected RpcReply( - final Document originalDocument, - final String namespacePrefix, - final String messageId, - final boolean ok, - @Singular("error") final List errors) { + /** + * Full constructor for RpcReply. + * + * @param namespacePrefix optional XML namespace prefix, may be {@code null} + * @param originalDocument the original Document, or {@code null} to build a new one + * @param messageId the message id + * @param ok whether the reply is ok + * @param errors the list of {@link RpcError} (must not be {@code null}) + * @param sessionId the session id + * @param capabilities the list of capabilities + */ + public RpcReply( + final String namespacePrefix, + final Document originalDocument, + final String messageId, + final boolean ok, + final List errors, + final String sessionId, + final List capabilities + ) { super(getDocument(originalDocument, namespacePrefix, messageId, ok, errors)); + this.namespacePrefix = namespacePrefix; this.messageId = messageId; this.ok = ok; this.errors = errors; + this.sessionId = sessionId; + this.capabilities = capabilities; } + /** + * Returns the XML Document for the reply, using the original or generating a new one. + * + * @param originalDocument the original XML document, or null + * @param namespacePrefix optional XML namespace prefix + * @param messageId the message ID + * @param ok true if reply is ok + * @param errors list of RpcError + * @return a valid Document object + */ private static Document getDocument( final Document originalDocument, final String namespacePrefix, final String messageId, final boolean ok, final List errors) { - if (originalDocument != null) { - return originalDocument; - } else { - return createDocument(namespacePrefix, messageId, ok, errors); - } + return Objects.requireNonNullElseGet(originalDocument, () -> createDocument(namespacePrefix, messageId, ok, errors)); } + /** + * Creates a new XML Document representing the rpc-reply. + * + * @param namespacePrefix optional XML namespace prefix + * @param messageId the message ID + * @param ok true if reply is ok + * @param errors list of RpcError + * @return a new Document instance + */ private static Document createDocument( final String namespacePrefix, final String messageId, @@ -175,12 +510,16 @@ private static Document createDocument( final Document createdDocument = createBlankDocument(); final Element rpcReplyElement = createdDocument.createElementNS(NetconfConstants.URN_XML_NS_NETCONF_BASE_1_0, "rpc-reply"); - rpcReplyElement.setPrefix(namespacePrefix); + if (namespacePrefix != null) { + rpcReplyElement.setPrefix(namespacePrefix); + } rpcReplyElement.setAttribute("message-id", messageId); createdDocument.appendChild(rpcReplyElement); if (ok) { final Element okElement = createdDocument.createElementNS(NetconfConstants.URN_XML_NS_NETCONF_BASE_1_0, "ok"); - okElement.setPrefix(namespacePrefix); + if (namespacePrefix != null) { + okElement.setPrefix(namespacePrefix); + } rpcReplyElement.appendChild(okElement); } appendErrors(namespacePrefix, errors, createdDocument, rpcReplyElement); @@ -188,24 +527,36 @@ private static Document createDocument( return createdDocument; } + /** + * Appends error elements to the rpc-reply XML structure. + * + * @param namespacePrefix optional XML namespace prefix + * @param errors list of RpcError + * @param createdDocument the Document to which elements are added + * @param parentElement the parent element to append to + */ protected static void appendErrors(final String namespacePrefix, final List errors, final Document createdDocument, final Element parentElement) { errors.forEach(error -> { final Element errorElement = createdDocument.createElementNS(NetconfConstants.URN_XML_NS_NETCONF_BASE_1_0, "rpc-error"); - errorElement.setPrefix(namespacePrefix); + if (namespacePrefix != null) { + errorElement.setPrefix(namespacePrefix); + } parentElement.appendChild(errorElement); - ofNullable(error.getErrorType()) + ofNullable(error.errorType()) .ifPresent(errorType-> appendElementWithText(createdDocument, errorElement, namespacePrefix, "error-type", errorType.getTextContent())); - ofNullable(error.getErrorTag()) + ofNullable(error.errorTag()) .ifPresent(errorTag -> appendElementWithText(createdDocument, errorElement, namespacePrefix, "error-tag", errorTag.getTextContent())); - ofNullable(error.getErrorSeverity()) + ofNullable(error.errorSeverity()) .ifPresent(errorSeverity -> appendElementWithText(createdDocument, errorElement, namespacePrefix, "error-severity", errorSeverity.getTextContent())); - appendElementWithText(createdDocument, errorElement, namespacePrefix, "error-path", error.getErrorPath()); - final Element errorMessageElement = appendElementWithText(createdDocument, errorElement, namespacePrefix, "error-message", error.getErrorMessage()); - ofNullable(error.getErrorMessageLanguage()) + appendElementWithText(createdDocument, errorElement, namespacePrefix, "error-path", error.errorPath()); + final Element errorMessageElement = appendElementWithText(createdDocument, errorElement, namespacePrefix, "error-message", error.errorMessage()); + ofNullable(error.errorMessageLanguage()) .ifPresent(errorMessageLanguage -> errorMessageElement.setAttribute("xml:lang", errorMessageLanguage)); - ofNullable(error.getErrorInfo()).ifPresent(errorInfo -> { + ofNullable(error.errorInfo()).ifPresent(errorInfo -> { final Element errorInfoElement = createdDocument.createElementNS(NetconfConstants.URN_XML_NS_NETCONF_BASE_1_0, "error-info"); - errorInfoElement.setPrefix(namespacePrefix); + if (namespacePrefix != null) { + errorInfoElement.setPrefix(namespacePrefix); + } errorElement.appendChild(errorInfoElement); appendElementWithText(createdDocument, errorInfoElement, namespacePrefix, "bad-attribute", errorInfo.getBadAttribute()); appendElementWithText(createdDocument, errorInfoElement, namespacePrefix, "bad-element", errorInfo.getBadElement()); @@ -217,4 +568,32 @@ protected static void appendErrors(final String namespacePrefix, final List errorList = getRpcErrors(document, xPath, XPATH_RPC_REPLY_LOAD_CONFIG_RESULT_ERROR); return RpcReplyLoadConfigResults.loadConfigResultsBuilder() + .originalDocument(document) + .namespacePrefix(null) .messageId(getAttribute(rpcReplyElement, "message-id")) .action(getAttribute(loadConfigResultsElement, "action")) .ok(rpcReplyOkElement != null) .errors(errorList) - .originalDocument(document) .build(); } - @Builder(builderMethodName = "loadConfigResultsBuilder") private RpcReplyLoadConfigResults( - final Document originalDocument, final String namespacePrefix, + final Document originalDocument, final String messageId, final String action, final boolean ok, - @Singular("error") final List errors) { - super(getDocument(originalDocument, namespacePrefix, messageId, action, ok, errors), - namespacePrefix, messageId, ok, errors); + final List errors) { + super( + namespacePrefix, + getDocument(originalDocument, namespacePrefix, messageId, action, ok, errors), + messageId, + ok, + errors, + null, + null + ); this.action = action; } + /** + * Returns a new {@link Builder} for constructing + * {@code RpcReplyLoadConfigResults} objects. + * + * @return a fresh {@link Builder} + */ + public static Builder loadConfigResultsBuilder() { + return new Builder(); + } + + /** + * Returns the value of the {@code action} attribute found in the + * <load-configuration-results> element. + * + * @return action attribute string + */ + public String getAction() { + return action; + } + + /** + * Builder for {@link RpcReplyLoadConfigResults}. + * Use this builder to construct immutable instances of RpcReplyLoadConfigResults. + */ + public static class Builder { + /** + * Creates an empty {@code Builder}. + */ + public Builder() { + } + private Document originalDocument; + private String namespacePrefix; + private String messageId; + private String action; + private boolean ok; + private List errors = new java.util.ArrayList<>(); + + /** + * Sets the original XML Document for the reply. + * @param originalDocument the XML Document, must not be null + * @return this Builder + * @throws NullPointerException if originalDocument is null + */ + public Builder originalDocument(Document originalDocument) { + this.originalDocument = Objects.requireNonNull(originalDocument, "originalDocument must not be null"); + return this; + } + + /** + * Sets the namespace prefix for the reply. + * @param namespacePrefix the prefix, may be null + * @return this Builder + */ + public Builder namespacePrefix(String namespacePrefix) { + this.namespacePrefix = namespacePrefix; + return this; + } + + /** + * Sets the message-id for the reply. + * @param messageId the message id, must not be null + * @return this Builder + * @throws NullPointerException if messageId is null + */ + public Builder messageId(String messageId) { + this.messageId = Objects.requireNonNull(messageId, "messageId must not be null"); + return this; + } + + /** + * Sets the action attribute for the reply. + * @param action the action, must not be null + * @return this Builder + * @throws NullPointerException if action is null + */ + public Builder action(String action) { + this.action = Objects.requireNonNull(action, "action must not be null"); + return this; + } + + /** + * Sets the ok flag for the reply. + * @param ok true if reply is ok, false otherwise + * @return this Builder + */ + public Builder ok(boolean ok) { + this.ok = ok; + return this; + } + + /** + * Sets the list of errors for the reply. + * @param errors the list of errors, must not be null + * @return this Builder + * @throws NullPointerException if errors is null + */ + public Builder errors(List errors) { + this.errors = new java.util.ArrayList<>(Objects.requireNonNull(errors, "errors list must not be null")); + return this; + } + + /** + * Adds a single error to the reply. + * @param error the error to add, ignored if null + * @return this Builder + */ + public Builder addError(RpcError error) { + if (error != null) { + this.errors.add(error); + } + return this; + } + + /** + * Builds the immutable RpcReplyLoadConfigResults instance. + * @return the built RpcReplyLoadConfigResults + */ + public RpcReplyLoadConfigResults build() { + return new RpcReplyLoadConfigResults( + namespacePrefix, // 1) prefix + originalDocument, // 2) document + messageId, // 3) message-id + action, // 4) action + ok, // 5) ok flag + errors // 6) error list + ); + } + } + private static Document getDocument( final Document originalDocument, final String namespacePrefix, @@ -106,4 +248,36 @@ private static Document createDocument( return createdDocument; } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof RpcReplyLoadConfigResults)) return false; + if (!super.equals(o)) return false; + RpcReplyLoadConfigResults that = (RpcReplyLoadConfigResults) o; + return Objects.equals(action, that.action); + } + + @Override + public int hashCode() { + return Objects.hash(super.hashCode(), action); + } + + @Override + public String toString() { + return "RpcReplyLoadConfigResults{" + + "action='" + action + '\'' + + "} " + super.toString(); + } + + /** + * Returns a defensive copy of the errors list to avoid exposing internal + * representation. + * + * @return copy of the error list + */ + @SuppressWarnings("unchecked") // parent class returns raw List + public List getErrors() { + return new java.util.ArrayList<>((List) super.getErrors()); + } } \ No newline at end of file diff --git a/src/test/java/net/juniper/netconf/CommitExceptionTest.java b/src/test/java/net/juniper/netconf/CommitExceptionTest.java index e265a27..ff0ccf5 100644 --- a/src/test/java/net/juniper/netconf/CommitExceptionTest.java +++ b/src/test/java/net/juniper/netconf/CommitExceptionTest.java @@ -1,11 +1,9 @@ package net.juniper.netconf; -import org.junit.Test; -import org.junit.experimental.categories.Category; +import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThatThrownBy; -@Category(Test.class) public class CommitExceptionTest { private static final String TEST_MESSAGE = "test message"; diff --git a/src/test/java/net/juniper/netconf/DatastoreTest.java b/src/test/java/net/juniper/netconf/DatastoreTest.java index b673e5f..59f1cdb 100644 --- a/src/test/java/net/juniper/netconf/DatastoreTest.java +++ b/src/test/java/net/juniper/netconf/DatastoreTest.java @@ -1,18 +1,16 @@ package net.juniper.netconf; import net.juniper.netconf.element.Datastore; -import org.junit.Test; - -import static org.hamcrest.Matchers.is; -import static org.junit.Assert.assertThat; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; public class DatastoreTest { @Test public void testDatastoreName() { - assertThat(Datastore.OPERATIONAL.toString(), is("operational")); - assertThat(Datastore.RUNNING.toString(), is("running")); - assertThat(Datastore.CANDIDATE.toString(), is("candidate")); - assertThat(Datastore.STARTUP.toString(), is("startup")); - assertThat(Datastore.INTENDED.toString(), is("intended")); + assertThat(Datastore.OPERATIONAL.toString()).isEqualTo("operational"); + assertThat(Datastore.RUNNING.toString()).isEqualTo("running"); + assertThat(Datastore.CANDIDATE.toString()).isEqualTo("candidate"); + assertThat(Datastore.STARTUP.toString()).isEqualTo("startup"); + assertThat(Datastore.INTENDED.toString()).isEqualTo("intended"); } } diff --git a/src/test/java/net/juniper/netconf/DeviceTest.java b/src/test/java/net/juniper/netconf/DeviceTest.java index c72d2db..505038a 100644 --- a/src/test/java/net/juniper/netconf/DeviceTest.java +++ b/src/test/java/net/juniper/netconf/DeviceTest.java @@ -5,9 +5,8 @@ import com.jcraft.jsch.JSch; import com.jcraft.jsch.JSchException; import com.jcraft.jsch.Session; -import org.junit.Before; -import org.junit.Test; -import org.junit.experimental.categories.Category; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; import org.xmlunit.assertj.XmlAssert; import java.io.ByteArrayInputStream; @@ -15,11 +14,10 @@ import java.io.IOException; import java.nio.charset.StandardCharsets; import java.util.Collections; +import java.util.List; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNull; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doThrow; @@ -29,31 +27,37 @@ import static org.mockito.Mockito.verifyNoMoreInteractions; import static org.mockito.Mockito.when; -@Category(Test.class) public class DeviceTest { private static final String TEST_HOSTNAME = "hostname"; private static final String TEST_USERNAME = "username"; private static final String TEST_PASSWORD = "password"; private static final int DEFAULT_NETCONF_PORT = 830; + private static final int OTHER_NETCONF_PORT = 990; private static final int DEFAULT_TIMEOUT = 5000; + private static final int OTHER_TIMEOUT = 1000; + private static final String TEST_FILENAME = "TEST_FILENAME"; private static final String SUBSYSTEM = "subsystem"; - private static final String HELLO_WITH_DEFAULT_CAPABILITIES = "" - + "\n" - + "\n" - + "urn:ietf:params:netconf:base:1.0\n" - + "urn:ietf:params:netconf:base:1.0#candidate\n" - + "urn:ietf:params:netconf:base:1.0#confirmed-commit\n" - + "urn:ietf:params:netconf:base:1.0#validate\n" - + "urn:ietf:params:netconf:base:1.0#url?protocol=http,ftp,file\n" - + "\n" - + ""; - private static final String HELLO_WITH_BASE_CAPABILITIES = "" - + "\n" - + "\n" - + "urn:ietf:params:netconf:base:1.0\n" - + "\n" - + ""; + private static final String HELLO_WITH_DEFAULT_CAPABILITIES = """ + \ + + + urn:ietf:params:netconf:base:1.0 + urn:ietf:params:netconf:base:1.0#candidate + urn:ietf:params:netconf:base:1.0#confirmed-commit + urn:ietf:params:netconf:base:1.0#validate + urn:ietf:params:netconf:base:1.0#url?protocol=http,ftp,file + urn:ietf:params:netconf:base:1.1 + + """; + private static final String HELLO_WITH_BASE_CAPABILITIES = """ + \ + + + urn:ietf:params:netconf:base:1.0 + urn:ietf:params:netconf:base:1.1 + + """; private ByteArrayOutputStream outputStream; @@ -66,7 +70,7 @@ private Device createTestDevice() throws NetconfException { .build(); } - @Before + @BeforeEach public void setUp() { outputStream = new ByteArrayOutputStream(); } @@ -80,9 +84,36 @@ public void GIVEN_requiredParameters_THEN_createDevice() throws NetconfException assertThat(device.getPort()).isEqualTo(DEFAULT_NETCONF_PORT); assertThat(device.getConnectionTimeout()).isEqualTo(DEFAULT_TIMEOUT); assertThat(device.getCommandTimeout()).isEqualTo(DEFAULT_TIMEOUT); - assertFalse(device.isKeyBasedAuthentication()); - assertNull(device.getPemKeyFile()); - assertNull(device.getHostKeysFileName()); + assertThat(device.isKeyBasedAuthentication()).isFalse(); + assertThat(device.getPemKeyFile()).isNull(); + assertThat(device.getHostKeysFileName()).isNull(); + } + + @Test + public void GIVEN_deviceBuilder_THEN_buildDevice() throws NetconfException { + Device device = Device.builder() + .hostName(TEST_HOSTNAME) + .userName(TEST_USERNAME) + .password(TEST_PASSWORD) + .port(OTHER_NETCONF_PORT) + .pemKeyFile(TEST_FILENAME) + .connectionTimeout(OTHER_TIMEOUT) + .commandTimeout(OTHER_TIMEOUT) + .keyBasedAuth(TEST_FILENAME) + .hostKeysFileName(TEST_FILENAME) + .build(); + assertThat(device.getHostName()).isEqualTo(TEST_HOSTNAME); + assertThat(device.getUserName()).isEqualTo(TEST_USERNAME); + assertThat(device.getPassword()).isEqualTo(TEST_PASSWORD); + assertThat(device.getPort()).isEqualTo(OTHER_NETCONF_PORT); + assertThat(device.getConnectionTimeout()).isEqualTo(OTHER_TIMEOUT); + assertThat(device.getCommandTimeout()).isEqualTo(OTHER_TIMEOUT); + assertThat(device.isKeyBasedAuthentication()).isTrue(); + assertThat(device.getPemKeyFile()).isEqualTo(TEST_FILENAME); + assertThat(device.getHostKeysFileName()).isEqualTo(TEST_FILENAME); + List caps = device.getDefaultClientCapabilities(); + assertThat(caps).isNotNull(); + assertThat(caps).contains("urn:ietf:params:netconf:base:1.0"); } @Test @@ -139,21 +170,21 @@ public void GIVEN_sshAvailableNetconfNot_THEN_closeDevice() throws Exception { @Test public void GIVEN_newDevice_WHEN_withNullUserName_THEN_throwsException() { assertThatThrownBy(() -> Device.builder().hostName("foo").build()) - .isInstanceOf(NullPointerException.class) - .hasMessage("userName is marked non-null but is null"); + .isInstanceOf(NetconfException.class) + .hasMessage("userName is required"); } @Test public void GIVEN_newDevice_WHEN_withHostName_THEN_throwsException() { assertThatThrownBy(() -> Device.builder().userName("foo").build()) - .isInstanceOf(NullPointerException.class) - .hasMessage("hostName is marked non-null but is null"); + .isInstanceOf(NetconfException.class) + .hasMessage("hostName is required"); } @Test public void GIVEN_newDevice_WHEN_checkIfConnected_THEN_returnFalse() throws NetconfException { Device device = createTestDevice(); - assertFalse(device.isConnected()); + assertThat(device.isConnected()).isFalse(); } @Test @@ -170,7 +201,7 @@ public void GIVEN_newDevice_WHEN_connect_THEN_sendHelloWithDefaultCapabilities() .build(); device.connect(); - final String message = outputStream.toString(); + final String message = outputStream.toString(StandardCharsets.UTF_8); assertThat(message).endsWith(NetconfConstants.DEVICE_PROMPT); final String hello = message.substring(0, message.length() - NetconfConstants.DEVICE_PROMPT.length()); XmlAssert.assertThat(hello) @@ -194,7 +225,7 @@ public void GIVEN_newDevice_WHEN_connect_THEN_sendHelloWithCustomCapabilities() .build(); device.connect(); - final String message = outputStream.toString(); + final String message = outputStream.toString(StandardCharsets.UTF_8); assertThat(message).endsWith(NetconfConstants.DEVICE_PROMPT); final String hello = message.substring(0, message.length() - NetconfConstants.DEVICE_PROMPT.length()); XmlAssert.assertThat(hello) diff --git a/src/test/java/net/juniper/netconf/LoadExceptionTest.java b/src/test/java/net/juniper/netconf/LoadExceptionTest.java index f4755b8..3b71409 100644 --- a/src/test/java/net/juniper/netconf/LoadExceptionTest.java +++ b/src/test/java/net/juniper/netconf/LoadExceptionTest.java @@ -1,10 +1,8 @@ package net.juniper.netconf; -import org.junit.Test; -import org.junit.experimental.categories.Category; +import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThatThrownBy; -@Category(Test.class) public class LoadExceptionTest { private static final String TEST_MESSAGE = "test message"; diff --git a/src/test/java/net/juniper/netconf/NetconfExceptionTest.java b/src/test/java/net/juniper/netconf/NetconfExceptionTest.java index d007171..7270e85 100644 --- a/src/test/java/net/juniper/netconf/NetconfExceptionTest.java +++ b/src/test/java/net/juniper/netconf/NetconfExceptionTest.java @@ -1,11 +1,9 @@ package net.juniper.netconf; -import org.junit.Test; -import org.junit.experimental.categories.Category; +import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThatThrownBy; -@Category(Test.class) public class NetconfExceptionTest { private static final String TEST_MESSAGE = "test message"; diff --git a/src/test/java/net/juniper/netconf/NetconfSessionTest.java b/src/test/java/net/juniper/netconf/NetconfSessionTest.java index fc495d1..25730b7 100644 --- a/src/test/java/net/juniper/netconf/NetconfSessionTest.java +++ b/src/test/java/net/juniper/netconf/NetconfSessionTest.java @@ -2,14 +2,15 @@ import com.google.common.base.Charsets; import com.jcraft.jsch.Channel; -import lombok.extern.slf4j.Slf4j; import net.juniper.netconf.element.RpcError; import net.juniper.netconf.element.RpcReply; -import org.junit.Before; -import org.junit.Test; -import org.junit.experimental.categories.Category; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; import org.mockito.Mock; import org.mockito.MockitoAnnotations; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.xmlunit.assertj.XmlAssert; import javax.xml.parsers.DocumentBuilder; @@ -27,29 +28,41 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.junit.Assert.assertThrows; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.Mockito.anyString; import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doCallRealMethod; import static org.mockito.Mockito.eq; import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -@Slf4j -@Category(Test.class) public class NetconfSessionTest { + private static final Logger log = LoggerFactory.getLogger(NetconfSessionTest.class); + public static final int CONNECTION_TIMEOUT = 2000; public static final int COMMAND_TIMEOUT = 5000; private static final String FAKE_HELLO = "fake hello"; + /** Automatically closes Mockito mocks opened in {@link #setUp()}. */ + private AutoCloseable closeable; + private static final String DEVICE_PROMPT = "]]>]]>"; - private static final byte[] DEVICE_PROMPT_BYTE = DEVICE_PROMPT.getBytes(); + private static final byte[] DEVICE_PROMPT_BYTE = DEVICE_PROMPT.getBytes(StandardCharsets.UTF_8); private static final String FAKE_RPC_REPLY = "fakedata"; + private static final String OK_RPC_REPLY = + ""; + private static final String ERROR_RPC_REPLY = + "" + + " " + + " protocol" + + " operation-failed" + + " error" + + " " + + ""; private static final String NETCONF_SYNTAX_ERROR_MSG_FROM_DEVICE = "netconf error: syntax error"; @Mock @@ -63,9 +76,9 @@ public class NetconfSessionTest { private PipedOutputStream outPipe; private PipedInputStream inPipe; - @Before + @BeforeEach public void setUp() throws IOException { - MockitoAnnotations.initMocks(this); + closeable = MockitoAnnotations.openMocks(this); inPipe = new PipedInputStream(8096); outPipe = new PipedOutputStream(inPipe); @@ -76,84 +89,75 @@ public void setUp() throws IOException { when(mockChannel.getOutputStream()).thenReturn(out); } + @AfterEach + public void tearDown() throws Exception { + if (closeable != null) { + closeable.close(); + } + } + @Test - public void GIVEN_getCandidateConfig_WHEN_syntaxError_THEN_throwNetconfException() throws Exception { + public void getCandidateConfigThrowsNetconfExceptionOnSyntaxError() throws Exception { when(mockNetconfSession.getCandidateConfig()).thenCallRealMethod(); when(mockNetconfSession.getRpcReply(anyString())).thenReturn(NETCONF_SYNTAX_ERROR_MSG_FROM_DEVICE); assertThatThrownBy(mockNetconfSession::getCandidateConfig) - .isInstanceOf(NetconfException.class) - .hasMessage("Invalid message from server: netconf error: syntax error"); + .isInstanceOf(NetconfException.class) + .hasMessage("Invalid message from server: netconf error: syntax error"); } @Test - public void GIVEN_getRunningConfig_WHEN_syntaxError_THEN_throwNetconfException() throws Exception { + public void getRunningConfigThrowsNetconfExceptionOnSyntaxError() throws Exception { when(mockNetconfSession.getRunningConfig()).thenCallRealMethod(); when(mockNetconfSession.getRpcReply(anyString())).thenReturn(NETCONF_SYNTAX_ERROR_MSG_FROM_DEVICE); assertThatThrownBy(mockNetconfSession::getRunningConfig) - .isInstanceOf(NetconfException.class) - .hasMessage("Invalid message from server: netconf error: syntax error"); + .isInstanceOf(NetconfException.class) + .hasMessage("Invalid message from server: netconf error: syntax error"); } @Test - public void GIVEN_createSession_WHEN_timeoutExceeded_THEN_throwSocketTimeoutException() { + public void createSessionThrowsSocketTimeoutExceptionWhenTimeoutExceeded() { Thread thread = new Thread(() -> { try { - outPipe.write(FAKE_RPC_REPLY.getBytes()); - for (int i = 0; i < 7; i++) { - outPipe.write(FAKE_RPC_REPLY.getBytes()); - Thread.sleep(200); - outPipe.flush(); - } - Thread.sleep(200); - outPipe.close(); + writeDataWithDelay(); } catch (IOException | InterruptedException e) { - log.error("error =", e); + log.error("Error in background thread", e); } }); thread.start(); assertThatThrownBy(() -> createNetconfSession(1000)) - .isInstanceOf(SocketTimeoutException.class) - .hasMessage("Command timeout limit was exceeded: 1000"); + .isInstanceOf(SocketTimeoutException.class) + .hasMessage("Command timeout limit was exceeded: 1000"); } @Test - public void GIVEN_createSession_WHEN_connectionClose_THEN_throwSocketTimeoutException() { + public void createSessionThrowsNetconfExceptionWhenConnectionCloses() { Thread thread = new Thread(() -> { try { - outPipe.write(FAKE_RPC_REPLY.getBytes()); - Thread.sleep(200); - outPipe.flush(); - Thread.sleep(200); - outPipe.close(); + writeDataAndClose(); } catch (IOException | InterruptedException e) { - log.error("error =", e); + log.error("Error in background thread", e); } }); thread.start(); assertThatThrownBy(() -> createNetconfSession(COMMAND_TIMEOUT)) - .isInstanceOf(NetconfException.class) - .hasMessage("Input Stream has been closed during reading."); + .isInstanceOf(NetconfException.class) + .hasMessage("Input Stream has been closed during reading."); } @Test - public void GIVEN_createSession_WHEN_devicePromptWithoutLF_THEN_correctResponse() throws Exception { + public void createSessionHandlesDevicePromptWithoutLineFeed() throws Exception { when(mockChannel.getInputStream()).thenReturn(inPipe); when(mockChannel.getOutputStream()).thenReturn(out); Thread thread = new Thread(() -> { try { - outPipe.write(FAKE_RPC_REPLY.getBytes()); - outPipe.write(DEVICE_PROMPT_BYTE); - Thread.sleep(200); - outPipe.flush(); - Thread.sleep(200); - outPipe.close(); + writeValidResponse(); } catch (IOException | InterruptedException e) { - log.error("error =", e); + log.error("Error in background thread", e); } }); thread.start(); @@ -162,26 +166,16 @@ public void GIVEN_createSession_WHEN_devicePromptWithoutLF_THEN_correctResponse( } @Test - public void GIVEN_executeRPC_WHEN_lldpRequest_THEN_correctResponse() throws Exception { + public void executeRpcReturnsCorrectResponseForLldpRequest() throws Exception { byte[] lldpResponse = Files.readAllBytes(TestHelper.getSampleFile("responses/lldpResponse.xml").toPath()); String expectedResponse = new String(lldpResponse, Charsets.UTF_8) - .replaceAll(NetconfConstants.CR, NetconfConstants.EMPTY_LINE) + NetconfConstants.LF; + .replaceAll(NetconfConstants.CR, NetconfConstants.EMPTY_LINE) + NetconfConstants.LF; Thread thread = new Thread(() -> { try { - outPipe.write(FAKE_RPC_REPLY.getBytes()); - outPipe.write(DEVICE_PROMPT_BYTE); - outPipe.flush(); - Thread.sleep(800); - outPipe.write(lldpResponse); - outPipe.flush(); - Thread.sleep(700); - outPipe.write(DEVICE_PROMPT_BYTE); - outPipe.flush(); - Thread.sleep(1900); - outPipe.close(); + writeLldpResponse(lldpResponse); } catch (IOException | InterruptedException e) { - log.error("error =", e); + log.error("Error in background thread", e); } }); thread.start(); @@ -197,215 +191,302 @@ public void GIVEN_executeRPC_WHEN_lldpRequest_THEN_correctResponse() throws Exce } @Test - public void GIVEN_executeRPC_WHEN_syntaxError_THEN_throwNetconfException() throws Exception { + public void executeRpcThrowsNetconfExceptionOnSyntaxError() throws Exception { when(mockNetconfSession.executeRPC(eq(TestConstants.LLDP_REQUEST))).thenCallRealMethod(); when(mockNetconfSession.getRpcReply(anyString())).thenReturn(NETCONF_SYNTAX_ERROR_MSG_FROM_DEVICE); assertThatThrownBy(() -> mockNetconfSession.executeRPC(TestConstants.LLDP_REQUEST)) - .isInstanceOf(NetconfException.class) - .hasMessage("Invalid message from server: netconf error: syntax error"); + .isInstanceOf(NetconfException.class) + .hasMessage("Invalid message from server: netconf error: syntax error"); } @Test - public void GIVEN_stringWithoutRPC_fixupRPC_THEN_returnStringWrappedWithRPCTags() { + public void fixupRpcWrapsStringWithoutRpcTags() { assertThat(NetconfSession.fixupRpc("fake string")) - .isEqualTo("" + DEVICE_PROMPT); + .isEqualTo("" + DEVICE_PROMPT); } @Test - public void GIVEN_stringWithRPCTags_fixupRPC_THEN_returnWrappedString() { + public void fixupRpcPreservesExistingRpcTags() { assertThat(NetconfSession.fixupRpc("fake string")) - .isEqualTo("fake string" + DEVICE_PROMPT); + .isEqualTo("fake string" + DEVICE_PROMPT); } @Test - public void GIVEN_stringWithTag_fixupRPC_THEN_returnWrappedString() { + public void fixupRpcWrapsTaggedString() { assertThat(NetconfSession.fixupRpc("")) - .isEqualTo("" + DEVICE_PROMPT); + .isEqualTo("" + DEVICE_PROMPT); } @Test - public void GIVEN_nullString_WHEN_fixupRPC_THEN_throwException() { + public void fixupRpcThrowsExceptionForNullString() { //noinspection ConstantConditions assertThatThrownBy(() -> NetconfSession.fixupRpc(null)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessage("Null RPC"); - } - - private NetconfSession createNetconfSession(int commandTimeout) throws IOException { - DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); - try { - builder = factory.newDocumentBuilder(); - } catch (ParserConfigurationException e) { - throw new NetconfException(String.format("Error creating XML Parser: %s", e.getMessage())); - } - - return new NetconfSession(mockChannel, CONNECTION_TIMEOUT, commandTimeout, FAKE_HELLO, builder); + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Null RPC"); } @Test - public void WHEN_instantiated_THEN_fetchHelloFromServer() throws Exception { - - final String hello = "" - + "\n" - + " \n" - + " urn:ietf:params:netconf:base:1.0\n" - + " urn:ietf:params:netconf:base:1.0#candidate\n" - + " urn:ietf:params:netconf:base:1.0#confirmed-commit\n" - + " urn:ietf:params:netconf:base:1.0#validate\n" - + " urn:ietf:params:netconf:base:1.0#url?protocol=http,ftp,file\n" - + " \n" - + " 27700\n" - + ""; - + public void instantiationFetchesHelloFromServer() throws Exception { + final String hello = createHelloMessage(); final ByteArrayInputStream is = new ByteArrayInputStream( - (hello + NetconfConstants.DEVICE_PROMPT) - .getBytes(StandardCharsets.UTF_8)); - when(mockChannel.getInputStream()) - .thenReturn(is); + (hello + NetconfConstants.DEVICE_PROMPT).getBytes(StandardCharsets.UTF_8)); + + when(mockChannel.getInputStream()).thenReturn(is); final NetconfSession netconfSession = createNetconfSession(100); - assertThat(netconfSession.getSessionId()) - .isEqualTo("27700"); + + assertThat(netconfSession.getSessionId()).isEqualTo("27700"); assertThat(netconfSession.getServerHello().hasCapability(NetconfConstants.URN_IETF_PARAMS_NETCONF_BASE_1_0)) .isTrue(); - } - private static void mockResponse(final InputStream is, final String message) throws IOException { - final String messageWithTerminator = message + NetconfConstants.DEVICE_PROMPT; - doAnswer(invocationOnMock -> { - final byte[] buffer = (byte[])invocationOnMock.getArguments()[0]; - final int offset = (int)invocationOnMock.getArguments()[1]; - final int bufferLength = (int)invocationOnMock.getArguments()[2]; - final byte[] messageBytes = messageWithTerminator.getBytes(StandardCharsets.UTF_8); - if(messageBytes.length > bufferLength ) { - throw new IllegalArgumentException("Requires more work for long messages"); - } - System.arraycopy(messageBytes, 0, buffer, offset, messageBytes.length); - return messageBytes.length; - }).when(is).read(any(), anyInt(), anyInt()); + @Test + public void loadTextConfigurationSucceedsWithOkResponse() throws Exception { + doCallRealMethod().when(mockNetconfSession) + .loadTextConfiguration(anyString(), anyString()); + when(mockNetconfSession.getRpcReply(anyString())).thenReturn(OK_RPC_REPLY); + when(mockNetconfSession.hasError()).thenReturn(false); + when(mockNetconfSession.isOK()).thenReturn(true); + + // should complete without throwing + mockNetconfSession.loadTextConfiguration("some config", "some type"); } @Test - public void loadTextConfigurationWillSucceedIfResponseIsOk() throws Exception { + public void loadTextConfigurationFailsWithNotOkResponse() throws Exception { + final String helloMessage = createHelloMessage(); + final RpcReply rpcReply = RpcReply.builder() + .ok(false) + .messageId("1") + .build(); + + final String combinedMessage = helloMessage + NetconfConstants.DEVICE_PROMPT + + rpcReply.getXml() + NetconfConstants.DEVICE_PROMPT; + + final InputStream combinedStream = new ByteArrayInputStream(combinedMessage.getBytes(StandardCharsets.UTF_8)); + when(mockChannel.getInputStream()).thenReturn(combinedStream); - final InputStream is = mock(InputStream.class); - when(mockChannel.getInputStream()) - .thenReturn(is); - mockResponse(is, ""); final NetconfSession netconfSession = createNetconfSession(100); + assertThrows(LoadException.class, + () -> netconfSession.loadTextConfiguration("some config", "some type")); + } + + @Test + public void loadTextConfigurationFailsWithOkResponseButErrors() throws Exception { + final String helloMessage = createHelloMessage(); final RpcReply rpcReply = RpcReply.builder() .ok(true) + .addError(RpcError.builder().errorSeverity(RpcError.ErrorSeverity.ERROR).build()) + .messageId("1") .build(); - mockResponse(is, rpcReply.getXml()); - - netconfSession.loadTextConfiguration("some config", "some type"); - verify(is, times(2)).read(any(), anyInt(), anyInt()); - } + final String combinedMessage = helloMessage + NetconfConstants.DEVICE_PROMPT + + rpcReply.getXml() + NetconfConstants.DEVICE_PROMPT; - @Test - public void loadTextConfigurationWillFailIfResponseIsNotOk() throws Exception { + final InputStream combinedStream = new ByteArrayInputStream(combinedMessage.getBytes(StandardCharsets.UTF_8)); + when(mockChannel.getInputStream()).thenReturn(combinedStream); - final InputStream is = mock(InputStream.class); - when(mockChannel.getInputStream()) - .thenReturn(is); - mockResponse(is, ""); final NetconfSession netconfSession = createNetconfSession(100); - final RpcReply rpcReply = RpcReply.builder() - .ok(false) - .build(); - mockResponse(is, rpcReply.getXml()); - assertThrows(LoadException.class, () -> netconfSession.loadTextConfiguration("some config", "some type")); + } - verify(is, times(2)).read(any(), anyInt(), anyInt()); + @Test + public void loadXmlConfigurationSucceedsWithOkResponse() throws Exception { + doCallRealMethod().when(mockNetconfSession) + .loadXMLConfiguration(anyString(), anyString()); + when(mockNetconfSession.getRpcReply(anyString())).thenReturn(OK_RPC_REPLY); + when(mockNetconfSession.hasError()).thenReturn(false); + when(mockNetconfSession.isOK()).thenReturn(true); + + // should complete without throwing + mockNetconfSession.loadXMLConfiguration("some config", "merge"); } @Test - public void loadTextConfigurationWillFailIfResponseIsOkWithErrors() throws Exception { + public void loadXmlConfigurationFailsWithNotOkResponse() throws Exception { + final String helloMessage = createHelloMessage(); + final RpcReply rpcReply = RpcReply.builder() + .ok(false) + .messageId("1") + .build(); + + final String combinedMessage = helloMessage + NetconfConstants.DEVICE_PROMPT + + rpcReply.getXml() + NetconfConstants.DEVICE_PROMPT; + + final InputStream combinedStream = new ByteArrayInputStream(combinedMessage.getBytes(StandardCharsets.UTF_8)); + when(mockChannel.getInputStream()).thenReturn(combinedStream); - final InputStream is = mock(InputStream.class); - when(mockChannel.getInputStream()) - .thenReturn(is); - mockResponse(is, ""); final NetconfSession netconfSession = createNetconfSession(100); + assertThrows(LoadException.class, + () -> netconfSession.loadXMLConfiguration("some config", "merge")); + } + + @Test + public void loadXmlConfigurationFailsWithOkResponseButErrors() throws Exception { + final String helloMessage = createHelloMessage(); final RpcReply rpcReply = RpcReply.builder() .ok(true) - .error(RpcError.builder().errorSeverity(RpcError.ErrorSeverity.ERROR).build()) + .addError(RpcError.builder().errorSeverity(RpcError.ErrorSeverity.ERROR).build()) + .messageId("1") .build(); - mockResponse(is, rpcReply.getXml()); - assertThrows(LoadException.class, - () -> netconfSession.loadTextConfiguration("some config", "some type")); + final String combinedMessage = helloMessage + NetconfConstants.DEVICE_PROMPT + + rpcReply.getXml() + NetconfConstants.DEVICE_PROMPT; + + final InputStream combinedStream = new ByteArrayInputStream(combinedMessage.getBytes(StandardCharsets.UTF_8)); + when(mockChannel.getInputStream()).thenReturn(combinedStream); + + final NetconfSession netconfSession = createNetconfSession(100); - verify(is, times(2)).read(any(), anyInt(), anyInt()); + assertThrows(LoadException.class, + () -> netconfSession.loadXMLConfiguration("some config", "merge")); } + /** + * RFC 6241 §7.9 – a successful <kill-session> returns <ok/>. + */ @Test - public void loadXmlConfigurationWillSucceedIfResponseIsOk() throws Exception { + public void killSessionReturnsTrueOnOkReply() throws Exception { + when(mockNetconfSession.killSession("42")).thenCallRealMethod(); + when(mockNetconfSession.getRpcReply(anyString())).thenReturn(OK_RPC_REPLY); + when(mockNetconfSession.hasError()).thenReturn(false); + when(mockNetconfSession.isOK()).thenReturn(true); - final InputStream is = mock(InputStream.class); - when(mockChannel.getInputStream()) - .thenReturn(is); - mockResponse(is, ""); - final NetconfSession netconfSession = createNetconfSession(100); + assertThat(mockNetconfSession.killSession("42")).isTrue(); + } - final RpcReply rpcReply = RpcReply.builder() - .ok(true) - .build(); - mockResponse(is, rpcReply.getXml()); + /** + * RFC 6241 §7.9 – if the server returns <rpc-error>, killSession() should return false. + */ + @Test + public void killSessionReturnsFalseOnErrorReply() throws Exception { + when(mockNetconfSession.killSession("99")).thenCallRealMethod(); + when(mockNetconfSession.getRpcReply(anyString())).thenReturn(ERROR_RPC_REPLY); + when(mockNetconfSession.hasError()).thenReturn(true); + when(mockNetconfSession.isOK()).thenReturn(false); - netconfSession.loadXMLConfiguration("some config", "merge"); + assertThat(mockNetconfSession.killSession("99")).isFalse(); + } + + /* ========================================================= + * :confirmed-commit:1.1 tests + * ========================================================= */ - verify(is, times(2)).read(any(), anyInt(), anyInt()); + @Test + public void commitConfirmWithPersistCompletesSuccessfullyOnOkReply() throws Exception { + doCallRealMethod().when(mockNetconfSession).commitConfirm(600, "abc"); + when(mockNetconfSession.getRpcReply(anyString())).thenReturn(OK_RPC_REPLY); + when(mockNetconfSession.hasError()).thenReturn(false); + when(mockNetconfSession.isOK()).thenReturn(true); + + // should complete without throwing CommitException + mockNetconfSession.commitConfirm(600, "abc"); } @Test - public void loadXmlConfigurationWillFailIfResponseIsNotOk() throws Exception { + public void commitConfirmWithPersistThrowsCommitExceptionOnErrorReply() throws Exception { + doCallRealMethod().when(mockNetconfSession).commitConfirm(600, "xyz"); + when(mockNetconfSession.getRpcReply(anyString())).thenReturn(ERROR_RPC_REPLY); + when(mockNetconfSession.hasError()).thenReturn(true); + when(mockNetconfSession.isOK()).thenReturn(false); + + assertThatThrownBy(() -> mockNetconfSession.commitConfirm(600, "xyz")) + .isInstanceOf(CommitException.class) + .hasMessage("Confirmed-commit operation returned error."); + } - final InputStream is = mock(InputStream.class); - when(mockChannel.getInputStream()) - .thenReturn(is); - mockResponse(is, ""); - final NetconfSession netconfSession = createNetconfSession(100); + /* ----- cancel-commit ----- */ - final RpcReply rpcReply = RpcReply.builder() - .ok(false) - .build(); + @Test + public void cancelCommitReturnsTrueOnOkReply() throws Exception { + when(mockNetconfSession.cancelCommit("abc")).thenCallRealMethod(); + when(mockNetconfSession.getRpcReply(anyString())).thenReturn(OK_RPC_REPLY); + when(mockNetconfSession.hasError()).thenReturn(false); + when(mockNetconfSession.isOK()).thenReturn(true); - mockResponse(is, rpcReply.getXml()); + assertThat(mockNetconfSession.cancelCommit("abc")).isTrue(); + } - assertThrows(LoadException.class, - () -> netconfSession.loadXMLConfiguration("some config", "merge")); + @Test + public void cancelCommitReturnsFalseOnErrorReply() throws Exception { + when(mockNetconfSession.cancelCommit(null)).thenCallRealMethod(); + when(mockNetconfSession.getRpcReply(anyString())).thenReturn(ERROR_RPC_REPLY); + when(mockNetconfSession.hasError()).thenReturn(true); + when(mockNetconfSession.isOK()).thenReturn(false); - verify(is, times(2)).read(any(), anyInt(), anyInt()); + assertThat(mockNetconfSession.cancelCommit(null)).isFalse(); } - @Test - public void loadXmlConfigurationWillFailIfResponseIsOkWithErrors() throws Exception { + // Helper methods to reduce code duplication and improve readability - final InputStream is = mock(InputStream.class); - when(mockChannel.getInputStream()) - .thenReturn(is); - mockResponse(is, ""); - final NetconfSession netconfSession = createNetconfSession(100); + private NetconfSession createNetconfSession(int commandTimeout) throws IOException { + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + try { + builder = factory.newDocumentBuilder(); + } catch (ParserConfigurationException e) { + throw new NetconfException(String.format("Error creating XML Parser: %s", e.getMessage())); + } - final RpcReply rpcReply = RpcReply.builder() - .ok(true) - .error(RpcError.builder().errorSeverity(RpcError.ErrorSeverity.ERROR).build()) - .build(); - mockResponse(is, rpcReply.getXml()); + return new NetconfSession(mockChannel, CONNECTION_TIMEOUT, commandTimeout, FAKE_HELLO, builder); + } - assertThrows(LoadException.class, - () -> netconfSession.loadXMLConfiguration("some config", "merge")); + private void writeDataWithDelay() throws IOException, InterruptedException { + outPipe.write(FAKE_RPC_REPLY.getBytes(StandardCharsets.UTF_8)); + for (int i = 0; i < 7; i++) { + outPipe.write(FAKE_RPC_REPLY.getBytes(StandardCharsets.UTF_8)); + Thread.sleep(200); + outPipe.flush(); + } + Thread.sleep(200); + outPipe.close(); + } - verify(is, times(2)).read(any(), anyInt(), anyInt()); + private void writeDataAndClose() throws IOException, InterruptedException { + outPipe.write(FAKE_RPC_REPLY.getBytes(StandardCharsets.UTF_8)); + Thread.sleep(200); + outPipe.flush(); + Thread.sleep(200); + outPipe.close(); } -} + private void writeValidResponse() throws IOException, InterruptedException { + outPipe.write(FAKE_RPC_REPLY.getBytes(StandardCharsets.UTF_8)); + outPipe.write(DEVICE_PROMPT_BYTE); + Thread.sleep(200); + outPipe.flush(); + Thread.sleep(200); + outPipe.close(); + } + + private void writeLldpResponse(byte[] lldpResponse) throws IOException, InterruptedException { + outPipe.write(FAKE_RPC_REPLY.getBytes(StandardCharsets.UTF_8)); + outPipe.write(DEVICE_PROMPT_BYTE); + outPipe.flush(); + Thread.sleep(800); + outPipe.write(lldpResponse); + outPipe.flush(); + Thread.sleep(700); + outPipe.write(DEVICE_PROMPT_BYTE); + outPipe.flush(); + Thread.sleep(1900); + outPipe.close(); + } + + private String createHelloMessage() { + return "\n" + + " \n" + + " urn:ietf:params:netconf:base:1.0\n" + + " urn:ietf:params:netconf:base:1.0#candidate\n" + + " urn:ietf:params:netconf:base:1.0#confirmed-commit\n" + + " urn:ietf:params:netconf:base:1.0#validate\n" + + " urn:ietf:params:netconf:base:1.0#url?protocol=http,ftp,file\n" + + " \n" + + " 27700\n" + + ""; + } +} \ No newline at end of file diff --git a/src/test/java/net/juniper/netconf/TestConstants.java b/src/test/java/net/juniper/netconf/TestConstants.java index e4357f1..32a95d4 100644 --- a/src/test/java/net/juniper/netconf/TestConstants.java +++ b/src/test/java/net/juniper/netconf/TestConstants.java @@ -1,5 +1,15 @@ package net.juniper.netconf; +/** + * Central location for NETCONF protocol constants used across the library. + *

+ * The values defined here correspond to RFC 6241 (base 1.0) and related drafts + * so that all modules reference a single, canonical source of truth rather than + * scattering string literals throughout the codebase. + *

+ * This class is a simple constant holder and is therefore marked {@code final} + * and given a private constructor to prevent instantiation. + */ public class TestConstants { public static final String CORRECT_HELLO = "\n" + diff --git a/src/test/java/net/juniper/netconf/XMLBuilderTest.java b/src/test/java/net/juniper/netconf/XMLBuilderTest.java new file mode 100644 index 0000000..8b64350 --- /dev/null +++ b/src/test/java/net/juniper/netconf/XMLBuilderTest.java @@ -0,0 +1,100 @@ +package net.juniper.netconf; + +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.Arrays; +import java.util.Collections; + +public class XMLBuilderTest { + + @Test + public void createNewConfig_twoElements_createsExpectedXML() throws Exception { + XMLBuilder builder = new XMLBuilder(); + XML xml = builder.createNewConfig("system", "services"); + assertThat(xml.toString()) + .containsIgnoringWhitespaces( + ""); + } + + @Test + public void createNewConfig_oneElement_createsExpectedXML() throws Exception { + XMLBuilder builder = new XMLBuilder(); + XML xml = builder.createNewConfig("system"); + assertThat(xml.toString()).containsIgnoringWhitespaces(""); + } + + @Test + public void createNewConfig_threeElements_createsExpectedXML() throws Exception { + XMLBuilder builder = new XMLBuilder(); + XML xml = builder.createNewConfig("system", "services", "ftp"); + assertThat(xml.toString()).containsIgnoringWhitespaces(""); + } + + @Test + public void createNewConfig_list_createsExpectedXML() throws Exception { + XMLBuilder builder = new XMLBuilder(); + XML xml = builder.createNewConfig(Arrays.asList("system", "services", "ftp")); + assertThat(xml.toString()).containsIgnoringWhitespaces(""); + } + + @Test + public void createNewConfig_emptyList_returnsNull() throws Exception { + XMLBuilder builder = new XMLBuilder(); + XML xml = builder.createNewConfig(Collections.emptyList()); + assertThat(xml).isNull(); + } + + @Test + public void createNewRPC_twoElements_createsExpectedXML() throws Exception { + XMLBuilder builder = new XMLBuilder(); + XML xml = builder.createNewRPC("get-interface-information", "terse"); + String xmlStr = xml.toString(); + + // Verify opening tag has message‑id and correct namespace (order irrelevant) + assertThat(xmlStr) + .matches("(?s).*]*message-id=\"\\d+\"[^>]*xmlns=\"urn:ietf:params:xml:ns:netconf:base:1.0\"[^>]*>.*"); + + // Verify payload hierarchy, ignoring whitespace/line breaks + assertThat(xmlStr) + .containsIgnoringWhitespaces(""); + } + + @Test + public void createNewXML_fourElements_createsExpectedXML() throws Exception { + XMLBuilder builder = new XMLBuilder(); + XML xml = builder.createNewXML("top", "middle", "sub", "leaf"); + assertThat(xml.toString()).containsIgnoringWhitespaces(""); + } + + @Test + public void createNewXML_list_createsExpectedXML() throws Exception { + XMLBuilder builder = new XMLBuilder(); + XML xml = builder.createNewXML(Arrays.asList("a", "b", "c")); + assertThat(xml.toString()).containsIgnoringWhitespaces(""); + } + + @Test + public void createNewXML_emptyList_returnsNull() throws Exception { + XMLBuilder builder = new XMLBuilder(); + XML xml = builder.createNewXML(Collections.emptyList()); + assertThat(xml).isNull(); + } + + @Test + public void createNewRPC_autoAddsMessageIdAndNamespace() throws Exception { + XMLBuilder builder = new XMLBuilder(); + XML xml = builder.createNewRPC("get", "running"); + String xmlStr = xml.toString(); + + // Assert the rpc element has a message-id attribute with a numeric value + assertThat(xmlStr) + .contains("message-id=\"") + .matches("(?s).*]*message-id=\"\\d+\"[^>]*xmlns=\"urn:ietf:params:xml:ns:netconf:base:1.0\"[^>]*>.*"); + + // Ensure hierarchy is intact + assertThat(xmlStr) + .containsIgnoringWhitespaces(""); + } +} diff --git a/src/test/java/net/juniper/netconf/XMLTest.java b/src/test/java/net/juniper/netconf/XMLTest.java index 08acef1..b3cf7db 100644 --- a/src/test/java/net/juniper/netconf/XMLTest.java +++ b/src/test/java/net/juniper/netconf/XMLTest.java @@ -1,6 +1,6 @@ package net.juniper.netconf; -import org.junit.Test; +import org.junit.jupiter.api.Test; import javax.xml.parsers.DocumentBuilder; import javax.xml.parsers.DocumentBuilderFactory; @@ -8,9 +8,13 @@ import java.util.Arrays; import java.util.Collections; import java.util.List; +import org.w3c.dom.Node; +import java.util.stream.Collectors; +import java.util.Map; import static net.juniper.netconf.TestHelper.getSampleFile; import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; public class XMLTest { @@ -45,4 +49,269 @@ public void GIVEN_sampleCliOutputRpc_WHEN_findValueOfSample_THEN_returnValue() t String expectedValue = "operational-response"; testFindValue(sampleFileName,findValueList, expectedValue); } -} + + @Test + public void GIVEN_missingElement_WHEN_findValue_THEN_returnNull() throws Exception { + String sampleFileName = "sampleMissingElement.xml"; + List findValueList = Arrays.asList( + "environment-component-information", + "environment-component-item", + "name~Nonexistent Component", + "temperature" + ); + String expectedValue = null; + testFindValue(sampleFileName, findValueList, expectedValue); + } + + @Test + public void GIVEN_missingFile_WHEN_findValue_THEN_throwException() throws Exception { + assertThrows(java.io.FileNotFoundException.class, () -> { + String sampleFileName = "nonexistent.xml"; + List findValueList = Collections.singletonList("any"); + testFindValue(sampleFileName, findValueList, "irrelevant"); + }); + } + + @Test + public void GIVEN_emptyFindValueList_WHEN_findValue_THEN_returnNull() throws Exception { + String sampleFileName = "sampleEmptyFPCTempRpcReply.xml"; + List findValueList = Collections.emptyList(); + String expectedValue = null; + testFindValue(sampleFileName, findValueList, expectedValue); + } + + @Test + public void GIVEN_simpleXml_WHEN_toString_THEN_returnXmlString() throws Exception { + XMLBuilder builder = new XMLBuilder(); + XML xml = builder.createNewXML("foo", "bar"); + + String xmlString = xml.toString(); + assertThat(xmlString).contains(""); + assertThat(xmlString).contains(""); + } + + @Test + public void GIVEN_twoIdenticalXmls_WHEN_equals_THEN_returnTrue() throws Exception { + XMLBuilder builder = new XMLBuilder(); + XML xml1 = builder.createNewXML("top", "child"); + XML xml2 = builder.createNewXML("top", "child"); + + org.xmlunit.assertj.XmlAssert.assertThat(xml1.toString()) + .and(xml2.toString()) + .areIdentical(); } + + @Test + public void GIVEN_xml_WHEN_hashCodeInvoked_THEN_noException() throws Exception { + XMLBuilder builder = new XMLBuilder(); + XML xml = builder.createNewXML("alpha", "beta"); + + int hash = xml.hashCode(); + assertThat(hash).isNotZero(); + } + + @Test + public void GIVEN_noTextInRoot_WHEN_findValue_THEN_returnNull() throws Exception { + XMLBuilder builder = new XMLBuilder(); + XML xml = builder.createNewXML("root"); + + String value = xml.findValue(Collections.singletonList("root")); + assertThat(value).isNull(); + } + + @Test + public void GIVEN_childNodeWithText_WHEN_findValue_THEN_returnText() throws Exception { + XMLBuilder builder = new XMLBuilder(); + XML xml = builder.createNewXML("parent"); + xml.addPath("child").setTextContent("hello"); + + String value = xml.findValue(Arrays.asList("child")); + assertThat(value).isEqualTo("hello"); + } + + @Test + public void GIVEN_parentWithTwoItems_WHEN_findNodes_item_THEN_returnBoth() throws Exception { + DocumentBuilder builder = factory.newDocumentBuilder(); + org.w3c.dom.Document doc = builder.newDocument(); + org.w3c.dom.Element parent = doc.createElement("parent"); + doc.appendChild(parent); + + org.w3c.dom.Element item1 = doc.createElement("item"); + item1.setTextContent("one"); + parent.appendChild(item1); + + org.w3c.dom.Element item2 = doc.createElement("item"); + item2.setTextContent("two"); + parent.appendChild(item2); + + XML xml = new XML(parent); + + List result = xml.findNodes(Collections.singletonList("item")); + assertThat(result).hasSize(2); + assertThat(result.stream().map(Node::getTextContent).collect(Collectors.toSet())) + .containsExactlyInAnyOrder("one", "two"); + } + /* ------------------------------------------------------------------ + * Junos‑specific XML attribute helpers + * ------------------------------------------------------------------ */ + + @Test + public void GIVEN_activeElement_WHEN_junosDeactivate_THEN_inactiveAttrSet() throws Exception { + XMLBuilder builder = new XMLBuilder(); + XML xml = builder.createNewXML("system"); + xml.junosDeactivate(); + + String inactive = xml.getOwnerDocument().getDocumentElement().getAttribute("inactive"); + assertThat(inactive).isEqualTo("inactive"); + } + + @Test + public void GIVEN_activeElement_WHEN_junosRename_THEN_renameAndNameAttrsSet() throws Exception { + XMLBuilder builder = new XMLBuilder(); + XML xml = builder.createNewXML("interface"); + xml.junosRename("ge-0/0/0", "ge-0/0/1"); + + org.w3c.dom.Element element = xml.getOwnerDocument().getDocumentElement(); + assertThat(element.getAttribute("rename")).isEqualTo("ge-0/0/0"); + assertThat(element.getAttribute("name")).isEqualTo("ge-0/0/1"); + } + + @Test + public void GIVEN_activeElement_WHEN_junosInsert_THEN_insertAndNameAttrsSet() throws Exception { + XMLBuilder builder = new XMLBuilder(); + XML xml = builder.createNewXML("policy"); + xml.junosInsert("before-me", "new-policy"); + + org.w3c.dom.Element element = xml.getOwnerDocument().getDocumentElement(); + assertThat(element.getAttribute("insert")).isEqualTo("before-me"); + assertThat(element.getAttribute("name")).isEqualTo("new-policy"); + } + + @Test + public void GIVEN_activeElement_WHEN_append_THEN_childAddedAndReturned() throws Exception { + XMLBuilder builder = new XMLBuilder(); + XML xmlParent = builder.createNewXML("configuration"); + + XML xmlChild = xmlParent.append("system"); + // Verify method returns a new XML pointing at the child + assertThat(xmlChild.getOwnerDocument().getDocumentElement().getNodeName()) + .isEqualTo("configuration"); + assertThat(xmlChild.toString()).contains(""); + + // Ensure the child element is actually appended under the parent + String full = xmlParent.getOwnerDocument().getDocumentElement().getTextContent(); + assertThat(full).isEmpty(); // configuration has one child but no text + } + + /* ------------------------------------------------------------------ + * Append helpers (element / text / map / array) + * ------------------------------------------------------------------ */ + + @Test + public void GIVEN_parent_WHEN_appendElementWithText_THEN_childContainsText() throws Exception { + XMLBuilder builder = new XMLBuilder(); + XML xmlParent = builder.createNewXML("config"); + XML xmlChild = xmlParent.append("hostname", "router1"); + + // verify returned XML is for + assertThat(xmlChild.getOwnerDocument().getDocumentElement().getNodeName()) + .isEqualTo("config"); + assertThat(xmlChild.toString()).contains("router1"); + } + + @Test + public void GIVEN_parent_WHEN_appendMultipleSameName_THEN_allChildrenAdded() throws Exception { + XMLBuilder builder = new XMLBuilder(); + XML xmlParent = builder.createNewXML("interfaces"); + xmlParent.append("unit", new String[] { "0", "1", "2" }); + + org.w3c.dom.NodeList units = + xmlParent.getOwnerDocument().getDocumentElement().getElementsByTagName("unit"); + assertThat(units.getLength()).isEqualTo(3); + } + + @Test + public void GIVEN_parent_WHEN_appendMap_THEN_childrenMatchMap() throws Exception { + XMLBuilder builder = new XMLBuilder(); + XML xmlParent = builder.createNewXML("system"); + Map map = Map.of("services", "on", + "location", "lab"); + xmlParent.append(map); + + assertThat(xmlParent.toString()) + .contains("on") + .contains("lab"); + } + + @Test + public void GIVEN_parent_WHEN_appendElementThenMap_THEN_newXMLContainsMapChildren() throws Exception { + XMLBuilder builder = new XMLBuilder(); + XML xmlParent = builder.createNewXML("configuration"); + + Map childMap = Map.of("rpc", "true", "ssh", "true"); + XML xmlServices = xmlParent.append("services", childMap); + + // ensure returned XML is level + assertThat(xmlServices.toString()) + .contains("true") + .contains("true"); + } + + /* ------------------------------------------------------------------ + * Sibling helper tests + * ------------------------------------------------------------------ */ + + @Test + public void GIVEN_child_WHEN_addSiblingElement_THEN_siblingAppended() throws Exception { + XMLBuilder builder = new XMLBuilder(); + XML xmlParent = builder.createNewXML("parent"); + XML xmlChild = xmlParent.addPath("child1"); + + xmlChild.addSibling("child2"); + + org.w3c.dom.NodeList children = + xmlParent.getOwnerDocument().getDocumentElement().getElementsByTagName("*"); + assertThat(children.getLength()).isEqualTo(2); + } + + @Test + public void GIVEN_child_WHEN_addSiblingWithText_THEN_textSet() throws Exception { + XMLBuilder builder = new XMLBuilder(); + XML xmlParent = builder.createNewXML("parent"); + XML xmlChild = xmlParent.addPath("item1"); + + xmlChild.addSibling("item2", "value"); + + org.w3c.dom.Element sibling = + (org.w3c.dom.Element) xmlParent.getOwnerDocument() + .getDocumentElement() + .getElementsByTagName("item2").item(0); + assertThat(sibling.getTextContent()).isEqualTo("value"); + } + + @Test + public void GIVEN_child_WHEN_addSiblingsArray_THEN_allAdded() throws Exception { + XMLBuilder builder = new XMLBuilder(); + XML xmlParent = builder.createNewXML("list"); + XML xmlChild = xmlParent.addPath("entry"); + + xmlChild.addSiblings("entry", new String[] { "a", "b" }); + + org.w3c.dom.NodeList entries = + xmlParent.getOwnerDocument().getDocumentElement().getElementsByTagName("entry"); + assertThat(entries.getLength()).isEqualTo(3); // original + 2 new + } + + @Test + public void GIVEN_child_WHEN_addSiblingsMap_THEN_allAdded() throws Exception { + XMLBuilder builder = new XMLBuilder(); + XML xmlParent = builder.createNewXML("data"); + XML xmlChild = xmlParent.addPath("level"); + + Map map = Map.of("alpha","1","beta","2"); + xmlChild.addSiblings(map); + + assertThat(xmlParent.toString()) + .contains("1") + .contains("2"); + } +} \ No newline at end of file diff --git a/src/test/java/net/juniper/netconf/element/DatastoreTest.java b/src/test/java/net/juniper/netconf/element/DatastoreTest.java new file mode 100644 index 0000000..4d6f7c6 --- /dev/null +++ b/src/test/java/net/juniper/netconf/element/DatastoreTest.java @@ -0,0 +1,40 @@ +package net.juniper.netconf.element; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class DatastoreTest { + + @Test + void testToStringReturnsLowercaseName() { + assertEquals("running", Datastore.RUNNING.toString()); + assertEquals("candidate", Datastore.CANDIDATE.toString()); + assertEquals("startup", Datastore.STARTUP.toString()); + assertEquals("intended", Datastore.INTENDED.toString()); + assertEquals("operational", Datastore.OPERATIONAL.toString()); + } + + @Test + void testFromXmlNameIsCaseInsensitive() { + assertEquals(Datastore.RUNNING, Datastore.fromXmlName("RUNNING")); + assertEquals(Datastore.STARTUP, Datastore.fromXmlName("Startup")); + assertEquals(Datastore.OPERATIONAL, Datastore.fromXmlName("operational")); + } + + @Test + void testFromXmlNameThrowsOnUnknown() { + Exception ex = assertThrows(IllegalArgumentException.class, () -> { + Datastore.fromXmlName("bogus"); + }); + assertTrue(ex.getMessage().contains("Unknown Datastore XML name")); + } + + @Test + void testFromXmlNameThrowsOnNull() { + Exception ex = assertThrows(IllegalArgumentException.class, () -> { + Datastore.fromXmlName(null); + }); + assertTrue(ex.getMessage().contains("cannot be null")); + } +} \ No newline at end of file diff --git a/src/test/java/net/juniper/netconf/element/HelloTest.java b/src/test/java/net/juniper/netconf/element/HelloTest.java index 3b9364a..bc36767 100644 --- a/src/test/java/net/juniper/netconf/element/HelloTest.java +++ b/src/test/java/net/juniper/netconf/element/HelloTest.java @@ -1,36 +1,44 @@ package net.juniper.netconf.element; -import org.junit.Test; +import org.junit.jupiter.api.Test; import org.xmlunit.assertj.XmlAssert; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.net.URI; +import java.net.URISyntaxException; public class HelloTest { // Samples taken from https://www.juniper.net/documentation/us/en/software/junos/netconf/topics/concept/netconf-session-rfc-compliant.html - public static final String HELLO_WITHOUT_NAMESPACE = "" - + "\n" - + " \n" - + " urn:ietf:params:netconf:base:1.0\n" - + " urn:ietf:params:netconf:base:1.0#candidate\n" - + " urn:ietf:params:netconf:base:1.0#confirmed-commit\n" - + " urn:ietf:params:netconf:base:1.0#validate\n" - + " urn:ietf:params:netconf:base:1.0#url?protocol=http,ftp,file\n" - + " \n" - + " 27700\n" - + ""; - - public static final String HELLO_WITH_NAMESPACE = "" + - "\n" - + " \n" - + " urn:ietf:params:netconf:base:1.0\n" - + " urn:ietf:params:netconf:base:1.0#candidate\n" - + " urn:ietf:params:netconf:base:1.0#confirmed-commit\n" - + " urn:ietf:params:netconf:base:1.0#validate\n" - + " urn:ietf:params:netconf:base:1.0#url?protocol=http,ftp,file\n" - + " \n" - + " 27703\n" - + ""; + public static final String HELLO_WITHOUT_NAMESPACE = """ + \ + + + urn:ietf:params:netconf:base:1.0 + urn:ietf:params:netconf:base:1.0#candidate + urn:ietf:params:netconf:base:1.0#confirmed-commit + urn:ietf:params:netconf:base:1.0#validate + urn:ietf:params:netconf:base:1.0#url?protocol=http,ftp,file + urn:ietf:params:netconf:base:1.1 + + 27700 + """; + + public static final String HELLO_WITH_NAMESPACE = """ + \ + + + urn:ietf:params:netconf:base:1.0 + urn:ietf:params:netconf:base:1.0#candidate + urn:ietf:params:netconf:base:1.0#confirmed-commit + urn:ietf:params:netconf:base:1.0#validate + urn:ietf:params:netconf:base:1.0#url?protocol=http,ftp,file + urn:ietf:params:netconf:base:1.1 + + 27703 + """; @Test public void willCreateAnObjectFromPacketWithoutNamespace() throws Exception { @@ -38,16 +46,17 @@ public void willCreateAnObjectFromPacketWithoutNamespace() throws Exception { final Hello hello = Hello.from(HELLO_WITHOUT_NAMESPACE); assertThat(hello.getSessionId()) - .isEqualTo("27700"); + .isEqualTo("27700"); assertThat(hello.getCapabilities()) - .containsExactly( - "urn:ietf:params:netconf:base:1.0", - "urn:ietf:params:netconf:base:1.0#candidate", - "urn:ietf:params:netconf:base:1.0#confirmed-commit", - "urn:ietf:params:netconf:base:1.0#validate", - "urn:ietf:params:netconf:base:1.0#url?protocol=http,ftp,file"); + .containsExactly( + "urn:ietf:params:netconf:base:1.0", + "urn:ietf:params:netconf:base:1.0#candidate", + "urn:ietf:params:netconf:base:1.0#confirmed-commit", + "urn:ietf:params:netconf:base:1.0#validate", + "urn:ietf:params:netconf:base:1.0#url?protocol=http,ftp,file", + "urn:ietf:params:netconf:base:1.1"); assertThat(hello.hasCapability("urn:ietf:params:netconf:base:1.0#candidate")) - .isTrue(); + .isTrue(); } @Test @@ -56,52 +65,158 @@ public void willCreateAnObjectFromPacketWithNamespace() throws Exception { final Hello hello = Hello.from(HELLO_WITH_NAMESPACE); assertThat(hello.getSessionId()) - .isEqualTo("27703"); + .isEqualTo("27703"); assertThat(hello.getCapabilities()) - .containsExactly( - "urn:ietf:params:netconf:base:1.0", - "urn:ietf:params:netconf:base:1.0#candidate", - "urn:ietf:params:netconf:base:1.0#confirmed-commit", - "urn:ietf:params:netconf:base:1.0#validate", - "urn:ietf:params:netconf:base:1.0#url?protocol=http,ftp,file"); + .containsExactly( + "urn:ietf:params:netconf:base:1.0", + "urn:ietf:params:netconf:base:1.0#candidate", + "urn:ietf:params:netconf:base:1.0#confirmed-commit", + "urn:ietf:params:netconf:base:1.0#validate", + "urn:ietf:params:netconf:base:1.0#url?protocol=http,ftp,file", + "urn:ietf:params:netconf:base:1.1"); assertThat(hello.hasCapability("urn:ietf:params:netconf:base:1.0#candidate")) - .isTrue(); + .isTrue(); } @Test public void willCreateXmlFromAnObject() { final Hello hello = Hello.builder() - .capability("urn:ietf:params:netconf:base:1.0") - .capability("urn:ietf:params:netconf:base:1.0#candidate") - .capability("urn:ietf:params:netconf:base:1.0#confirmed-commit") - .capability("urn:ietf:params:netconf:base:1.0#validate") - .capability("urn:ietf:params:netconf:base:1.0#url?protocol=http,ftp,file") - .sessionId("27700") - .build(); + .capability("urn:ietf:params:netconf:base:1.0") + .capability("urn:ietf:params:netconf:base:1.0#candidate") + .capability("urn:ietf:params:netconf:base:1.0#confirmed-commit") + .capability("urn:ietf:params:netconf:base:1.0#validate") + .capability("urn:ietf:params:netconf:base:1.0#url?protocol=http,ftp,file") + .capability("urn:ietf:params:netconf:base:1.1") + + .sessionId("27700") + .build(); XmlAssert.assertThat(hello.getXml()) - .and(HELLO_WITHOUT_NAMESPACE) - .ignoreWhitespace() - .areIdentical(); + .and(HELLO_WITHOUT_NAMESPACE) + .ignoreWhitespace() + .areIdentical(); } @Test public void willCreateXmlWithNamespaceFromAnObject() { final Hello hello = Hello.builder() - .namespacePrefix("nc") - .capability("urn:ietf:params:netconf:base:1.0") - .capability("urn:ietf:params:netconf:base:1.0#candidate") - .capability("urn:ietf:params:netconf:base:1.0#confirmed-commit") - .capability("urn:ietf:params:netconf:base:1.0#validate") - .capability("urn:ietf:params:netconf:base:1.0#url?protocol=http,ftp,file") - .sessionId("27703") - .build(); + .namespacePrefix("nc") + .capability("urn:ietf:params:netconf:base:1.0") + .capability("urn:ietf:params:netconf:base:1.0#candidate") + .capability("urn:ietf:params:netconf:base:1.0#confirmed-commit") + .capability("urn:ietf:params:netconf:base:1.0#validate") + .capability("urn:ietf:params:netconf:base:1.0#url?protocol=http,ftp,file") + .sessionId("27703") + .build(); XmlAssert.assertThat(hello.getXml()) - .and(HELLO_WITH_NAMESPACE) - .ignoreWhitespace() - .areIdentical(); + .and(HELLO_WITH_NAMESPACE) + .ignoreWhitespace() + .areIdentical(); + } + + @Test + public void willHandleEmptyCapabilities() { + Hello hello = Hello.builder() + .sessionId("99999") + .build(); + + // Base capability 1.1 is auto‑injected by the builder + assertThat(hello.getCapabilities()) + .containsExactly("urn:ietf:params:netconf:base:1.1"); + assertThat(hello.getSessionId()).isEqualTo("99999"); + } + + @Test + public void willHandleNullSessionId() { + Hello hello = Hello.builder() + .capability("urn:ietf:params:netconf:base:1.0") + .build(); + + assertThat(hello.getSessionId()).isNull(); + } + + @Test + public void willThrowOnMalformedXml() { + String badXml = "urn"; + assertThatThrownBy(() -> Hello.from(badXml)) + .isInstanceOf(Exception.class); + } + + @Test + public void willRoundTripNamespaceXml() throws Exception { + Hello original = Hello.from(HELLO_WITH_NAMESPACE); + Hello roundTripped = Hello.from(original.getXml()); + assertThat(roundTripped).isEqualTo(original); + } + + @Test + public void differentObjectsNotEqual() { + Hello h1 = Hello.builder().sessionId("1").build(); + Hello h2 = Hello.builder().sessionId("2").build(); + assertThat(h1).isNotEqualTo(h2); + } + + @Test + public void willHandleNullCapabilityCheck() { + Hello hello = Hello.builder().build(); + assertThat(hello.hasCapability(null)).isFalse(); + } + + /** + * RFC 6241 §3.1 – capability names MUST be valid URIs. + * Supplying an invalid capability string to the builder should + * throw an IllegalArgumentException. + */ + @Test + public void willRejectNonUriCapability() { + String bogus = "not a uri"; + assertThatThrownBy(() -> Hello.builder() + .sessionId("42") + .capability(bogus) + .build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Capability MUST be a valid URI per RFC 3986:"); + } + + @Test + void builderAddsBase11CapabilityByDefault() { + Hello hello = Hello.builder().sessionId("123").build(); + assertThat(hello.getCapabilities()) + .contains("urn:ietf:params:netconf:base:1.1"); + } + + @Test + public void willRejectDtdInXml() { + String withDtd = """ + + ]> + + 1 + + """; + + assertThatThrownBy(() -> Hello.from(withDtd)) + .isInstanceOf(Exception.class) + .hasMessageContaining("DOCTYPE"); + } + + /** + * Ensures every capability returned by Hello#getCapabilities() parses as a URI. + */ + @Test + public void capabilitiesAreUris() throws URISyntaxException { + Hello hello = Hello.builder() + .sessionId("99") + .capability("urn:ietf:params:netconf:base:1.0") + .capability("urn:ietf:params:netconf:capability:writable-running:1.0") + .build(); + + for (String cap : hello.getCapabilities()) { + new URI(cap); // throws URISyntaxException if invalid + } } } \ No newline at end of file diff --git a/src/test/java/net/juniper/netconf/element/RpcErrorTest.java b/src/test/java/net/juniper/netconf/element/RpcErrorTest.java new file mode 100644 index 0000000..8d6d0c9 --- /dev/null +++ b/src/test/java/net/juniper/netconf/element/RpcErrorTest.java @@ -0,0 +1,69 @@ +package net.juniper.netconf.element; + +import net.juniper.netconf.element.RpcError.ErrorSeverity; +import net.juniper.netconf.element.RpcError.ErrorTag; +import net.juniper.netconf.element.RpcError.ErrorType; +import net.juniper.netconf.element.RpcError.RpcErrorInfo; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.*; + +/** + * Unit tests for the {@link RpcError} record and its helper enums / builder. + */ +class RpcErrorTest { + + @Test + void builderCreatesEquivalentRecord() { + RpcErrorInfo info = RpcErrorInfo.builder() + .badAttribute("attr") + .sessionId("101") + .build(); + + RpcError fromBuilder = RpcError.builder() + .errorType(ErrorType.RPC) + .errorTag(ErrorTag.INVALID_VALUE) + .errorSeverity(ErrorSeverity.ERROR) + .errorPath("/interfaces/interface[name='xe-0/0/0']") + .errorMessage("invalid value") + .errorMessageLanguage("en") + .errorInfo(info) + .build(); + + RpcError direct = new RpcError(ErrorType.RPC, + ErrorTag.INVALID_VALUE, + ErrorSeverity.ERROR, + "/interfaces/interface[name='xe-0/0/0']", + "invalid value", + "en", + info); + + assertThat(fromBuilder).isEqualTo(direct); + assertThat(fromBuilder.hashCode()).isEqualTo(direct.hashCode()); + } + + @Test + void enumsRoundTripFromString() { + assertThat(ErrorType.from("protocol")).isEqualTo(ErrorType.PROTOCOL); + assertThat(ErrorTag.from("unknown-element")).isEqualTo(ErrorTag.UNKNOWN_ELEMENT); + assertThat(ErrorSeverity.from("warning")).isEqualTo(ErrorSeverity.WARNING); + + // unknown returns null + assertThat(ErrorTag.from("does-not-exist")).isNull(); + } + + @Test + void toStringContainsKeyFields() { + RpcError error = RpcError.builder() + .errorType(ErrorType.TRANSPORT) + .errorTag(ErrorTag.LOCK_DENIED) + .errorSeverity(ErrorSeverity.ERROR) + .errorMessage("lock denied") + .build(); + + String txt = error.toString(); + assertThat(txt).contains("TRANSPORT") + .contains("LOCK_DENIED") + .contains("lock denied"); + } +} diff --git a/src/test/java/net/juniper/netconf/element/RpcReplyLoadConfigResultsTest.java b/src/test/java/net/juniper/netconf/element/RpcReplyLoadConfigResultsTest.java index cf64c36..2a49778 100644 --- a/src/test/java/net/juniper/netconf/element/RpcReplyLoadConfigResultsTest.java +++ b/src/test/java/net/juniper/netconf/element/RpcReplyLoadConfigResultsTest.java @@ -1,29 +1,31 @@ package net.juniper.netconf.element; -import org.junit.Test; +import org.junit.jupiter.api.Test; import org.xmlunit.assertj.XmlAssert; import static org.assertj.core.api.Assertions.assertThat; public class RpcReplyLoadConfigResultsTest { - private static final String LOAD_CONFIG_RESULTS_OK_NO_NAMESPACE = "" - + "\n" + - " \n" + - " \n" + - " \n" + - ""; - - private static final String LOAD_CONFIG_RESULTS_OK_WITH_NAMESPACE = "" - + "\n" + - " \n" + - " \n" + - " \n" + - ""; + private static final String LOAD_CONFIG_RESULTS_OK_NO_NAMESPACE = """ + \ + + + + + """; + + private static final String LOAD_CONFIG_RESULTS_OK_WITH_NAMESPACE = """ + \ + + + + + """; private static final String LOAD_CONFIG_RESULTS_ERROR_NO_NAMESPACE = "" + "\n" + "\n"; - private static final String LOAD_CONFIG_RESULTS_ERROR_WITH_NAMESPACE = "" - + "\n" + - " \n" + - " \n" + - " protocol\n" + - " operation-failed\n" + - " error\n" + - " syntax error\n" + - " \n" + - " foobar\n" + - " \n" + - " \n" + - " \n" + - " \n" + - "\n"; + private static final String LOAD_CONFIG_RESULTS_ERROR_WITH_NAMESPACE = """ + \ + + + + protocol + operation-failed + error + syntax error + + foobar + + + + + + """; @Test public void willParseAnOkResponseWithNoNamespacePrefix() throws Exception { @@ -70,7 +74,7 @@ public void willParseAnOkResponseWithNoNamespacePrefix() throws Exception { .isEqualTo("3"); assertThat(rpcReply.getAction()) .isEqualTo("set"); - assertThat(rpcReply.isOk()) + assertThat(rpcReply.isOK()) .isTrue(); assertThat(rpcReply.hasErrorsOrWarnings()) .isFalse(); @@ -92,7 +96,7 @@ public void willParseAnOkResponseWithNamespacePrefix() throws Exception { .isEqualTo("4"); assertThat(rpcReply.getAction()) .isEqualTo("set"); - assertThat(rpcReply.isOk()) + assertThat(rpcReply.isOK()) .isTrue(); assertThat(rpcReply.hasErrorsOrWarnings()) .isFalse(); @@ -114,7 +118,7 @@ public void willParseAnErrorResponseWithoutNamespacePrefix() throws Exception { .isEqualTo("5"); assertThat(rpcReply.getAction()) .isEqualTo("set"); - assertThat(rpcReply.isOk()) + assertThat(rpcReply.isOK()) .isTrue(); assertThat(rpcReply.hasErrorsOrWarnings()) .isTrue(); @@ -143,7 +147,7 @@ public void willParseAnErrorResponseWithNamespacePrefix() throws Exception { .isEqualTo("6"); assertThat(rpcReply.getAction()) .isEqualTo("set"); - assertThat(rpcReply.isOk()) + assertThat(rpcReply.isOK()) .isTrue(); assertThat(rpcReply.hasErrorsOrWarnings()) .isTrue(); @@ -201,7 +205,7 @@ public void willCreateXmlErrorWithoutNamespace() { .messageId("5") .action("set") .ok(true) - .error(RpcError.builder() + .addError(RpcError.builder() .errorType(RpcError.ErrorType.PROTOCOL) .errorTag(RpcError.ErrorTag.OPERATION_FAILED) .errorSeverity(RpcError.ErrorSeverity.ERROR) @@ -226,7 +230,7 @@ public void willCreateXmlErrorWithNamespace() { .messageId("6") .action("set") .ok(true) - .error(RpcError.builder() + .addError(RpcError.builder() .errorType(RpcError.ErrorType.PROTOCOL) .errorTag(RpcError.ErrorTag.OPERATION_FAILED) .errorSeverity(RpcError.ErrorSeverity.ERROR) diff --git a/src/test/java/net/juniper/netconf/element/RpcReplyTest.java b/src/test/java/net/juniper/netconf/element/RpcReplyTest.java index 65eeb96..24ef97c 100644 --- a/src/test/java/net/juniper/netconf/element/RpcReplyTest.java +++ b/src/test/java/net/juniper/netconf/element/RpcReplyTest.java @@ -1,11 +1,12 @@ package net.juniper.netconf.element; -import org.junit.Test; +import org.junit.jupiter.api.Test; import org.xmlunit.assertj.XmlAssert; import java.util.Arrays; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; public class RpcReplyTest { @@ -42,13 +43,19 @@ public class RpcReplyTest { " \n" + ""; + private static final String MALFORMED_RPC_REPLY = ""; + + // Same OK reply but framed with RFC 6242 §4.3 delimiter + private static final String RPC_REPLY_WITH_OK_FRAMED = + RPC_REPLY_WITH_OK + "]]>]]>"; + @Test public void willParseRpcReplyWithoutNamespace() throws Exception { final RpcReply rpcReply = RpcReply.from(RPC_REPLY_WITHOUT_NAMESPACE); assertThat(rpcReply.getMessageId()) .isEqualTo("3"); - assertThat(rpcReply.isOk()) + assertThat(rpcReply.isOK()) .isFalse(); assertThat(rpcReply.hasErrorsOrWarnings()) .isFalse(); @@ -64,7 +71,7 @@ public void willParseRpcReplyWithNamespace() throws Exception { assertThat(rpcReply.getMessageId()) .isEqualTo("4"); - assertThat(rpcReply.isOk()) + assertThat(rpcReply.isOK()) .isFalse(); assertThat(rpcReply.hasErrorsOrWarnings()) .isFalse(); @@ -80,7 +87,7 @@ public void willCreateOkRpcReply() throws Exception { assertThat(rpcReply.getMessageId()) .isEqualTo("5"); - assertThat(rpcReply.isOk()) + assertThat(rpcReply.isOK()) .isTrue(); assertThat(rpcReply.hasErrorsOrWarnings()) .isFalse(); @@ -96,7 +103,7 @@ public void willParseRpcReplyWithErrors() throws Exception { assertThat(rpcReply.getMessageId()) .isEqualTo("101"); - assertThat(rpcReply.isOk()) + assertThat(rpcReply.isOK()) .isFalse(); assertThat(rpcReply.hasErrorsOrWarnings()) .isTrue(); @@ -168,7 +175,7 @@ public void willCreateXmlWithOkFromAnObject() { public void willCreateXmlWithErrors() { final RpcReply rpcReply = RpcReply.builder() .messageId("101") - .error(RpcError.builder() + .addError(RpcError.builder() .errorType(RpcError.ErrorType.APPLICATION) .errorTag(RpcError.ErrorTag.INVALID_VALUE) .errorSeverity(RpcError.ErrorSeverity.ERROR) @@ -176,7 +183,7 @@ public void willCreateXmlWithErrors() { .errorMessage("MTU value 25000 is not within range 256..9192") .errorMessageLanguage("en") .build()) - .error(RpcError.builder() + .addError(RpcError.builder() .errorType(RpcError.ErrorType.APPLICATION) .errorTag(RpcError.ErrorTag.INVALID_VALUE) .errorSeverity(RpcError.ErrorSeverity.ERROR) @@ -190,4 +197,40 @@ public void willCreateXmlWithErrors() { .ignoreWhitespace() .areIdentical(); } + + /** + * RFC 6241 §4.3: a peer SHOULD respond with + * if the incoming message is not well‑formed XML. In our client-side parser we + * expect a SAXException (wrapped) rather than a valid RpcReply object. + */ + @Test + public void willThrowOnMalformedXml() { + assertThatThrownBy(() -> RpcReply.from(MALFORMED_RPC_REPLY)) + .isInstanceOf(Exception.class); // Xml parsing failed (SAXException or wrapped) + } + + /** + * RFC 6241 requires UTF‑8 encoding. Passing bytes in ISO‑8859‑1 that contain + * invalid UTF‑8 sequences should also raise a parse failure. + */ + @Test + public void willThrowOnNonUtf8Encoding() { + byte[] isoBytes = MALFORMED_RPC_REPLY.getBytes(java.nio.charset.StandardCharsets.ISO_8859_1); + String wrongEncoded = new String(isoBytes, java.nio.charset.StandardCharsets.ISO_8859_1); // contains invalid UTF‑8 if any high‑bytes + assertThatThrownBy(() -> RpcReply.from(wrongEncoded)) + .isInstanceOf(Exception.class); + } + /** + * RFC 6242 §4.3: parser must ignore the "]]>]]>" end‑of‑message delimiter + * that legacy :base:1.0 peers append. + */ + @Test + public void willParseRpcReplyWithDelimiter() throws Exception { + final RpcReply rpcReply = RpcReply.from(RPC_REPLY_WITH_OK_FRAMED); + + assertThat(rpcReply.getMessageId()) + .isEqualTo("5"); + assertThat(rpcReply.isOK()).isTrue(); + assertThat(rpcReply.hasErrorsOrWarnings()).isFalse(); + } } \ No newline at end of file diff --git a/src/test/java/net/juniper/netconf/integration/INTEGRATION_TESTS.md b/src/test/java/net/juniper/netconf/integration/INTEGRATION_TESTS.md new file mode 100644 index 0000000..90f7f24 --- /dev/null +++ b/src/test/java/net/juniper/netconf/integration/INTEGRATION_TESTS.md @@ -0,0 +1,53 @@ +# NetConf Java Integration Tests + +This directory contains integration tests for the netconf-java library that test against real network devices. + +## Overview + +The integration tests verify: +- Basic device connection and authentication +- Server capabilities retrieval +- Configuration retrieval via get-config +- Multiple sequential connections +- Error handling and timeouts +- Device-specific RPC operations + +## Running the Tests + +### Method 1: Using JUnit (Recommended) + +```bash +# Run with interactive prompts +mvn test -Dtest=NetconfIntegrationTest -Dnetconf.integration.enabled=true + +# Run with predefined credentials +mvn test -Dtest=NetconfIntegrationTest -Dnetconf.integration.enabled=true \ + -Dnetconf.host=192.168.1.1 \ + -Dnetconf.username=admin \ + -Dnetconf.password=secret \ + -Dnetconf.port=830 +``` + +### Method 2: Using the Shell Script + +```bash +# Make the script executable +chmod +x run-integration-tests.sh + +# Run with interactive prompts +./run-integration-tests.sh + +# Run with command line arguments +./run-integration-tests.sh --host 192.168.1.1 --username admin --password secret +``` + +### Method 3: Manual Test Runner + +For environments where JUnit is not available: + +```bash +# Compile the manual runner +javac -cp "target/classes:target/dependency/*" src/test/java/net/juniper/netconf/integration/ManualTestRunner.java + +# Run the manual tests +java -cp "target/classes:target/dependency/*:src/test/java" net.juniper.netconf.integration. \ No newline at end of file diff --git a/src/test/java/net/juniper/netconf/integration/NetconfIntegrationTest.java b/src/test/java/net/juniper/netconf/integration/NetconfIntegrationTest.java new file mode 100644 index 0000000..252937f --- /dev/null +++ b/src/test/java/net/juniper/netconf/integration/NetconfIntegrationTest.java @@ -0,0 +1,351 @@ +package net.juniper.netconf.integration; + +import net.juniper.netconf.Device; +import net.juniper.netconf.NetconfException; +import net.juniper.netconf.XML; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.condition.EnabledIfSystemProperty; + +import java.io.Console; +import java.io.IOException; +import java.util.Scanner; +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Integration tests for netconf-java library against real network devices. + * + * To run these tests: + * mvn test -Dtest=NetconfIntegrationTest -Dnetconf.integration.enabled=true + * + * Or run with specific device details: + * mvn test -Dtest=NetconfIntegrationTest -Dnetconf.integration.enabled=true \ + * -Dnetconf.host=192.168.1.1 -Dnetconf.username=admin -Dnetconf.password=secret + */ +@EnabledIfSystemProperty(named = "netconf.integration.enabled", matches = "true") +public class NetconfIntegrationTest { + + private static String hostname; + private static String username; + private static String password; + private static int port = 830; // Default NETCONF port + private static int timeout = 30000; // 30 seconds + + @BeforeAll + static void setupCredentials() { + // Try to get credentials from system properties first + hostname = System.getProperty("netconf.host"); + username = System.getProperty("netconf.username"); + password = System.getProperty("netconf.password"); + + String portStr = System.getProperty("netconf.port"); + if (portStr != null) { + port = Integer.parseInt(portStr); + } + + String timeoutStr = System.getProperty("netconf.timeout"); + if (timeoutStr != null) { + timeout = Integer.parseInt(timeoutStr); + } + + // If not provided via system properties, prompt user + if (hostname == null || username == null || password == null) { + Console console = System.console(); + Scanner scanner = new Scanner(System.in, StandardCharsets.UTF_8); + + System.out.println("=== NETCONF Integration Test Setup ==="); + + if (hostname == null) { + System.out.print("Enter device hostname/IP: "); + hostname = console != null ? console.readLine() : scanner.nextLine(); + } + + if (username == null) { + System.out.print("Enter username: "); + username = console != null ? console.readLine() : scanner.nextLine(); + } + + if (password == null) { + if (console != null) { + char[] passwordChars = console.readPassword("Enter password: "); + password = new String(passwordChars); + } else { + System.out.print("Enter password: "); + password = scanner.nextLine(); + } + } + + System.out.print("Enter port (default 830): "); + String portInput = console != null ? console.readLine() : scanner.nextLine(); + if (!portInput.trim().isEmpty()) { + port = Integer.parseInt(portInput.trim()); + } + + System.out.println("Using connection details:"); + System.out.println(" Host: " + hostname); + System.out.println(" Username: " + username); + System.out.println(" Port: " + port); + System.out.println(" Timeout: " + timeout + "ms"); + System.out.println(); + } + + assertNotNull(hostname, "Hostname must be provided"); + assertNotNull(username, "Username must be provided"); + assertNotNull(password, "Password must be provided"); + } + + @Test + @DisplayName("Test device connection and basic capabilities") + void testDeviceConnection() throws NetconfException { + System.out.println("Testing device connection..."); + + Device device = Device.builder() + .hostName(hostname) + .userName(username) + .password(password) + .port(port) + .connectionTimeout(timeout) + .strictHostKeyChecking(false) + .build(); + + try { + device.connect(); + assertTrue(device.isConnected(), "Device should be connected"); + + // Test getting server capabilities + String[] capabilities = device.getNetconfCapabilities().toArray(new String[0]); + assertNotNull(capabilities, "Server capabilities should not be null"); + assertTrue(capabilities.length > 0, "Server should have at least one capability"); + + System.out.println("✓ Successfully connected to device"); + System.out.println("✓ Server capabilities count: " + capabilities.length); + + // Print some capabilities for debugging + System.out.println("Server capabilities:"); + for (int i = 0; i < Math.min(5, capabilities.length); i++) { + System.out.println(" " + capabilities[i]); + } + if (capabilities.length > 5) { + System.out.println(" ... and " + (capabilities.length - 5) + " more"); + } + + } finally { + if (device.isConnected()) { + device.close(); + assertFalse(device.isConnected(), "Device should be disconnected after close"); + } + } + } + + @Test + @DisplayName("Test basic get-config operation") + void testGetConfig() throws + org.xml.sax.SAXException, IOException { + System.out.println("Testing get-config operation..."); + + Device device = Device.builder() + .hostName(hostname) + .userName(username) + .password(password) + .port(port) + .connectionTimeout(timeout) + .strictHostKeyChecking(false) + .build(); + + try { + device.connect(); + + // Test get-config with running datastore + XML rpcReply = device.executeRPC(""); + + assertNotNull(rpcReply, "RPC reply should not be null"); + + String response = rpcReply.toString(); + assertNotNull(response, "RPC reply string should not be null"); + assertFalse(response.isEmpty(), "RPC reply should not be empty"); + assertTrue(response.contains("rpc-reply"), "Response should contain rpc-reply"); + + System.out.println("✓ Successfully executed get-config"); + System.out.println("✓ Response length: " + response.length() + " characters"); + + } finally { + if (device.isConnected()) { + device.close(); + } + } + } + + @Test + @DisplayName("Test get operation with interface information") + void testGetInterfaceInformation() throws org.xml.sax.SAXException, IOException { + System.out.println("Testing get interface information..."); + + Device device = Device.builder() + .hostName(hostname) + .userName(username) + .password(password) + .port(port) + .connectionTimeout(timeout) + .strictHostKeyChecking(false) + .build(); + + try { + device.connect(); + + // Try different RPC formats to test flexibility + String[] rpcFormats = { + "get-interface-information", + "", + "" + }; + + boolean atLeastOneSucceeded = false; + + for (String rpc : rpcFormats) { + try { + System.out.println("Trying RPC format: " + rpc); + XML rpcReply = device.executeRPC(rpc); + + assertNotNull(rpcReply, "RPC reply should not be null for format: " + rpc); + String response = rpcReply.toString(); + assertFalse(response.isEmpty(), "RPC reply should not be empty for format: " + rpc); + + System.out.println("✓ RPC format '" + rpc + "' succeeded"); + atLeastOneSucceeded = true; + + } catch (NetconfException e) { + System.out.println("✗ RPC format '" + rpc + "' failed: " + e.getMessage()); + // Continue trying other formats + } + } + + // At least one format should work on a properly configured device + // Note: This might fail on non-Juniper devices, which is expected + if (!atLeastOneSucceeded) { + System.out.println("ℹ None of the interface information RPC formats succeeded. " + + "This might be normal for non-Juniper devices."); + } + + } finally { + if (device.isConnected()) { + device.close(); + } + } + } + + @Test + @DisplayName("Test connection timeout handling") + void testConnectionTimeout() throws NetconfException { + System.out.println("Testing connection timeout handling..."); + + // Use an unreachable IP to test timeout + long startTime; + try (Device device = Device.builder() + .hostName("192.0.2.1") // RFC 5737 test IP - should be unreachable + .userName("test") + .password("test") + .port(830) + .strictHostKeyChecking(false) + .connectionTimeout(5000) // 5 second timeout + .build()) { + + startTime = System.currentTimeMillis(); + + assertThrows(NetconfException.class, device::connect, + "Connection to unreachable host should throw NetconfException"); + } + + long elapsedTime = System.currentTimeMillis() - startTime; + + // Should timeout within reasonable time (allow some variance) + assertTrue(elapsedTime < 10000, + "Timeout should occur within 10 seconds, but took " + elapsedTime + "ms"); + + System.out.println("✓ Connection timeout handled correctly in " + elapsedTime + "ms"); + } + + @Test + @DisplayName("Test multiple sequential connections") + void testMultipleConnections() throws NetconfException { + System.out.println("Testing multiple sequential connections..."); + + for (int i = 1; i <= 3; i++) { + System.out.println("Connection attempt " + i + "/3"); + + Device device = Device.builder() + .hostName(hostname) + .userName(username) + .password(password) + .port(port) + .strictHostKeyChecking(false) + .connectionTimeout(timeout) + .build(); + + try { + device.connect(); + assertTrue(device.isConnected(), "Device should be connected on attempt " + i); + + // Brief operation to ensure connection is working + int capabilityCount = device.getNetconfCapabilities().size(); + assertTrue(capabilityCount > 0, "Should have capabilities on attempt " + i); + + } finally { + if (device.isConnected()) { + device.close(); + assertFalse(device.isConnected(), "Device should be disconnected after close on attempt " + i); + } + } + + // Small delay between connections + try { + Thread.sleep(1000); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } + } + + System.out.println("✓ Multiple sequential connections completed successfully"); + } + + @Test + @DisplayName("Test RPC error handling") + void testRPCErrorHandling() throws NetconfException { + System.out.println("Testing RPC error handling..."); + + Device device = Device.builder() + .hostName(hostname) + .userName(username) + .password(password) + .port(port) + .strictHostKeyChecking(false) + .connectionTimeout(timeout) + .build(); + + try { + device.connect(); + + // Send an intentionally malformed RPC + assertThrows(Exception.class, () -> device.executeRPC( + ""), + "Invalid RPC should throw an exception"); + + System.out.println("✓ RPC error handling works correctly"); + + // Verify connection is still usable after error + int capabilityCount = device.getNetconfCapabilities().size(); + assertTrue(capabilityCount > 0, + "Connection should still be usable after RPC error"); + + System.out.println("✓ Connection remains usable after RPC error"); + + } finally { + if (device.isConnected()) { + device.close(); + } + } + } +} \ No newline at end of file diff --git a/src/test/java/net/juniper/netconf/integration/RUN-CRPD-CONTAINER.md b/src/test/java/net/juniper/netconf/integration/RUN-CRPD-CONTAINER.md new file mode 100644 index 0000000..68dc858 --- /dev/null +++ b/src/test/java/net/juniper/netconf/integration/RUN-CRPD-CONTAINER.md @@ -0,0 +1,86 @@ +# Running Juniper cRPD for integration tests + +This project’s integration tests need a live NETCONF endpoint. +You can spin up Juniper’s containerised Routing Protocol Daemon (**cRPD**) locally in < 1 minute. + +--- + +## 1 . Prerequisites + +* **Docker + Colima** (or Docker Desktop) on macOS + ```bash + brew install colima docker docker-compose + colima start # arm64 by default; add --arch x86_64 if you need an amd64 VM + ``` + +* **cRPD image** (free evaluation tarball from Juniper) + Place it under `src/test/resources/` as shown below. + +--- + +## 2 . Load the image into Docker + +```bash +docker load < src/test/resources/junos-routing-crpd-docker-23.2R1.13-arm64.tgz +# or …-amd64… if you’re running a colima --arch x86_64 VM +``` + +Verify: + +```bash +docker images | grep crpd +# crpd 23.2R1.13 0cf5ad… 498MB +``` + +--- + +## 3 . Start cRPD with NETCONF and SSH exposed + +```bash +docker run -d --name crpd1 --privileged \ + -p 2222:22 \ # SSH + -p 1830:830 \ # NETCONF + crpd:23.2R1.13 +``` + +Wait ~40 s, then configure a test user and enable NETCONF: + +```bash +docker exec -ti crpd1 cli + +# inside Junos CLI +configure +set system root-authentication plain-text-password +# (enter a password, e.g. Junos123) +set system login user test uid 2000 class super-user +set system login user test authentication plain-text-password +# (password: test1234) + +set system services ssh +set system services netconf ssh +commit and-quit +``` + +--- + +## 4 . Run the integration test wrapper + +```bash +./src/test/java/net/juniper/netconf/integration/run-integration-tests.sh \ + --username test \ + --password test1234 \ + --host localhost \ + --port 1830 +``` + +The script builds the library, spins up JUnit tests, and targets the NETCONF service you just started. + +--- + +### Cleaning up + +```bash +docker rm -f crpd1 # stop & remove the container +``` + +That’s it! You now have a repeatable way to launch a Junos device for automated NETCONF testing. \ No newline at end of file diff --git a/src/test/java/net/juniper/netconf/integration/run-integration-tests.sh b/src/test/java/net/juniper/netconf/integration/run-integration-tests.sh new file mode 100755 index 0000000..9067daf --- /dev/null +++ b/src/test/java/net/juniper/netconf/integration/run-integration-tests.sh @@ -0,0 +1,123 @@ +#!/bin/bash + +# run-integration-tests.sh +# Script to run netconf-java integration tests with interactive credential prompts + +set -e + +echo "=== NetConf Java Integration Test Runner ===" +echo "This script will run integration tests against a real network device." +echo "You will be prompted for connection details if not provided via environment." +echo "" + +# Check if Maven is available +if ! command -v mvn &> /dev/null; then + echo "Error: Maven is not installed or not in PATH" + exit 1 +fi + +# Parse command line arguments +INTERACTIVE=true +SKIP_COMPILE=false + +while [[ $# -gt 0 ]]; do + case $1 in + --host) + NETCONF_HOST="$2" + shift 2 + ;; + --username) + NETCONF_USERNAME="$2" + shift 2 + ;; + --password) + NETCONF_PASSWORD="$2" + shift 2 + ;; + --port) + NETCONF_PORT="$2" + shift 2 + ;; + --timeout) + NETCONF_TIMEOUT="$2" + shift 2 + ;; + --skip-compile) + SKIP_COMPILE=true + shift + ;; + --help|-h) + echo "Usage: $0 [options]" + echo "Options:" + echo " --host Device hostname or IP address" + echo " --username SSH username" + echo " --password SSH password" + echo " --port NETCONF port (default: 830)" + echo " --timeout Connection timeout in milliseconds (default: 30000)" + echo " --skip-compile Skip Maven compile phase" + echo " --help, -h Show this help message" + echo "" + echo "Environment variables can also be used:" + echo " NETCONF_HOST, NETCONF_USERNAME, NETCONF_PASSWORD, NETCONF_PORT, NETCONF_TIMEOUT" + exit 0 + ;; + *) + echo "Unknown option: $1" + echo "Use --help for usage information" + exit 1 + ;; + esac +done + +# Set defaults from environment if not provided via command line +NETCONF_HOST=${NETCONF_HOST:-$NETCONF_HOST} +NETCONF_USERNAME=${NETCONF_USERNAME:-$NETCONF_USERNAME} +NETCONF_PASSWORD=${NETCONF_PASSWORD:-$NETCONF_PASSWORD} +NETCONF_PORT=${NETCONF_PORT:-830} +NETCONF_TIMEOUT=${NETCONF_TIMEOUT:-30000} + +# Compile the project first (unless skipped) +if [ "$SKIP_COMPILE" = false ]; then + echo "Compiling project..." + mvn compile test-compile -q + if [ $? -ne 0 ]; then + echo "Error: Failed to compile project" + exit 1 + fi + echo "✓ Project compiled successfully" + echo "" +fi + +# Build Maven command +MVN_CMD="mvn test -Dtest=NetconfIntegrationTest -Dnetconf.integration.enabled=true" + +# Add system properties if provided +if [ -n "$NETCONF_HOST" ]; then + MVN_CMD="$MVN_CMD -Dnetconf.host=$NETCONF_HOST" +fi + +if [ -n "$NETCONF_USERNAME" ]; then + MVN_CMD="$MVN_CMD -Dnetconf.username=$NETCONF_USERNAME" +fi + +if [ -n "$NETCONF_PASSWORD" ]; then + MVN_CMD="$MVN_CMD -Dnetconf.password=$NETCONF_PASSWORD" +fi + +if [ -n "$NETCONF_PORT" ]; then + MVN_CMD="$MVN_CMD -Dnetconf.port=$NETCONF_PORT" +fi + +if [ -n "$NETCONF_TIMEOUT" ]; then + MVN_CMD="$MVN_CMD -Dnetconf.timeout=$NETCONF_TIMEOUT" +fi + +echo "Running integration tests..." +echo "Command: $MVN_CMD" +echo "" + +# Execute the tests +eval $MVN_CMD + +echo "" +echo "=== Integration Tests Complete ===" \ No newline at end of file diff --git a/src/test/resources/sampleEmptyFPCTempRpcReply.xml b/src/test/resources/sampleEmptyFPCTempRpcReply.xml new file mode 100644 index 0000000..acaac66 --- /dev/null +++ b/src/test/resources/sampleEmptyFPCTempRpcReply.xml @@ -0,0 +1,7 @@ + + + + Routing Engine 0 + 41 degrees C / 105 degrees F + + \ No newline at end of file diff --git a/src/test/resources/sampleMissingElement.xml b/src/test/resources/sampleMissingElement.xml new file mode 100644 index 0000000..acaac66 --- /dev/null +++ b/src/test/resources/sampleMissingElement.xml @@ -0,0 +1,7 @@ + + + + Routing Engine 0 + 41 degrees C / 105 degrees F + + \ No newline at end of file