mirror of
https://github.com/NationalSecurityAgency/ghidra.git
synced 2024-11-23 12:42:30 +00:00
GP-1387: Porting GDB/SSH to JSch
This commit is contained in:
parent
b2a553073f
commit
072ab7435a
@ -0,0 +1 @@
|
||||
MODULE FILE LICENSE: lib/jsch-0.1.55.jar JSch License
|
@ -27,6 +27,7 @@ dependencies {
|
||||
api project(':Framework-Debugging')
|
||||
api project(':Debugger-gadp')
|
||||
api project(':Python')
|
||||
api 'com.jcraft:jsch:0.1.55'
|
||||
|
||||
testImplementation project(path: ':Framework-AsyncComm', configuration: 'testArtifacts')
|
||||
testImplementation project(path: ':Framework-Debugging', configuration: 'testArtifacts')
|
||||
|
@ -1,4 +1,4 @@
|
||||
##VERSION: 2.0
|
||||
##MODULE IP: Jython License
|
||||
##MODULE IP: JSch License
|
||||
Module.manifest||GHIDRA||||END|
|
||||
data/scripts/define_info_proc_mappings||GHIDRA||||END|
|
||||
|
@ -30,7 +30,7 @@ import ghidra.dbg.util.ConfigurableFactory.FactoryDescription;
|
||||
htmlDetails = "Launch a GDB session over an SSH connection")
|
||||
public class GdbOverSshDebuggerModelFactory implements DebuggerModelFactory {
|
||||
|
||||
private String gdbCmd = "gdb";
|
||||
private String gdbCmd = "/usr/bin/gdb";
|
||||
@FactoryOption("GDB launch command")
|
||||
public final Property<String> gdbCommandOption =
|
||||
Property.fromAccessors(String.class, this::getGdbCommand, this::setGdbCommand);
|
||||
@ -40,29 +40,29 @@ public class GdbOverSshDebuggerModelFactory implements DebuggerModelFactory {
|
||||
public final Property<Boolean> useExistingOption =
|
||||
Property.fromAccessors(boolean.class, this::isUseExisting, this::setUseExisting);
|
||||
|
||||
private String hostname = "localhost";
|
||||
private String hostname = GhidraSshPtyFactory.DEFAULT_HOSTNAME;
|
||||
@FactoryOption("SSH hostname")
|
||||
public final Property<String> hostnameOption =
|
||||
Property.fromAccessors(String.class, this::getHostname, this::setHostname);
|
||||
|
||||
private int port = 22;
|
||||
private int port = GhidraSshPtyFactory.DEFAULT_PORT;
|
||||
@FactoryOption("SSH TCP port")
|
||||
public final Property<Integer> portOption =
|
||||
Property.fromAccessors(Integer.class, this::getPort, this::setPort);
|
||||
|
||||
private String username = "user";
|
||||
private String username = GhidraSshPtyFactory.DEFAULT_USERNAME;
|
||||
@FactoryOption("SSH username")
|
||||
public final Property<String> usernameOption =
|
||||
Property.fromAccessors(String.class, this::getUsername, this::setUsername);
|
||||
|
||||
private String keyFile = "";
|
||||
@FactoryOption("SSH identity (blank for password auth)")
|
||||
private String configFile = GhidraSshPtyFactory.DEFAULT_CONFIG_FILE;
|
||||
@FactoryOption("Open SSH config file")
|
||||
public final Property<String> keyFileOption =
|
||||
Property.fromAccessors(String.class, this::getKeyFile, this::setKeyFile);
|
||||
Property.fromAccessors(String.class, this::getConfigFile, this::setConfigFile);
|
||||
|
||||
// Always default to false, despite local system, because remote is likely Linux.
|
||||
private boolean useCrlf = false;
|
||||
@FactoryOption("Use DOS line endings (unchecked for UNIX)")
|
||||
@FactoryOption("Use DOS line endings (unchecked for UNIX remote)")
|
||||
public final Property<Boolean> crlfNewLineOption =
|
||||
Property.fromAccessors(Boolean.class, this::isUseCrlf, this::setUseCrlf);
|
||||
|
||||
@ -73,7 +73,7 @@ public class GdbOverSshDebuggerModelFactory implements DebuggerModelFactory {
|
||||
GhidraSshPtyFactory factory = new GhidraSshPtyFactory();
|
||||
factory.setHostname(hostname);
|
||||
factory.setPort(port);
|
||||
factory.setKeyFile(keyFile);
|
||||
factory.setConfigFile(configFile);
|
||||
factory.setUsername(username);
|
||||
return new GdbModelImpl(factory);
|
||||
}).thenCompose(model -> {
|
||||
@ -136,12 +136,12 @@ public class GdbOverSshDebuggerModelFactory implements DebuggerModelFactory {
|
||||
this.username = username;
|
||||
}
|
||||
|
||||
public String getKeyFile() {
|
||||
return keyFile;
|
||||
public String getConfigFile() {
|
||||
return configFile;
|
||||
}
|
||||
|
||||
public void setKeyFile(String keyFile) {
|
||||
this.keyFile = keyFile;
|
||||
public void setConfigFile(String configFile) {
|
||||
this.configFile = configFile;
|
||||
}
|
||||
|
||||
public boolean isUseCrlf() {
|
||||
|
@ -1,54 +0,0 @@
|
||||
/* ###
|
||||
* IP: GHIDRA
|
||||
*
|
||||
* 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
|
||||
*
|
||||
* http://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.
|
||||
*/
|
||||
package agent.gdb.pty.ssh;
|
||||
|
||||
import ch.ethz.ssh2.KnownHosts;
|
||||
import ch.ethz.ssh2.ServerHostKeyVerifier;
|
||||
import docking.widgets.OptionDialog;
|
||||
import ghidra.util.Msg;
|
||||
|
||||
public class GhidraSshHostKeyVerifier implements ServerHostKeyVerifier {
|
||||
|
||||
private final KnownHosts database;
|
||||
|
||||
public GhidraSshHostKeyVerifier(KnownHosts database) {
|
||||
this.database = database;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean verifyServerHostKey(String hostname, int port, String serverHostKeyAlgorithm,
|
||||
byte[] serverHostKey) throws Exception {
|
||||
switch (database.verifyHostkey(hostname, serverHostKeyAlgorithm, serverHostKey)) {
|
||||
case KnownHosts.HOSTKEY_IS_OK:
|
||||
return true;
|
||||
case KnownHosts.HOSTKEY_IS_NEW:
|
||||
int response = OptionDialog.showYesNoDialogWithNoAsDefaultButton(null,
|
||||
"Unknown SSH Server Host Key",
|
||||
"<html><b>The server " + hostname + " is not known.</b> " +
|
||||
"It is highly recommended you log in to the server using a standard " +
|
||||
"SSH client to confirm the host key first.<br><br>" +
|
||||
"Do you want to continue?</html>");
|
||||
return response == OptionDialog.YES_OPTION;
|
||||
case KnownHosts.HOSTKEY_HAS_CHANGED:
|
||||
Msg.showError(this, null, "SSH Server Host Key Changed",
|
||||
"<html><b>The server " + hostname + " has a different key than before!</b>" +
|
||||
"Use a standard SSH client to resolve the issue.</html>");
|
||||
return false;
|
||||
default:
|
||||
throw new IllegalStateException();
|
||||
}
|
||||
}
|
||||
}
|
@ -15,25 +15,147 @@
|
||||
*/
|
||||
package agent.gdb.pty.ssh;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.util.Objects;
|
||||
|
||||
import javax.swing.JOptionPane;
|
||||
|
||||
import org.apache.commons.text.StringEscapeUtils;
|
||||
|
||||
import com.jcraft.jsch.*;
|
||||
import com.jcraft.jsch.ConfigRepository.Config;
|
||||
|
||||
import agent.gdb.pty.PtyFactory;
|
||||
import ch.ethz.ssh2.Connection;
|
||||
import ch.ethz.ssh2.KnownHosts;
|
||||
import docking.DockingWindowManager;
|
||||
import docking.widgets.PasswordDialog;
|
||||
import ghidra.util.Msg;
|
||||
import ghidra.util.exception.CancelledException;
|
||||
import ghidra.util.*;
|
||||
|
||||
public class GhidraSshPtyFactory implements PtyFactory {
|
||||
private String hostname = "localhost";
|
||||
private int port = 22;
|
||||
private String username = "user";
|
||||
private String keyFile = "~/.ssh/id_rsa";
|
||||
private static final String TITLE = "GDB via SSH";
|
||||
private static final int WRAP_LEN = 80;
|
||||
|
||||
private Connection sshConn;
|
||||
public static final String DEFAULT_HOSTNAME = "localhost";
|
||||
public static final int DEFAULT_PORT = 22;
|
||||
public static final String DEFAULT_USERNAME = "user";
|
||||
public static final String DEFAULT_CONFIG_FILE = "~/.ssh/config";
|
||||
|
||||
private class RequireTTYAlwaysConfig implements Config {
|
||||
private final Config delegate;
|
||||
|
||||
public RequireTTYAlwaysConfig(Config delegate) {
|
||||
this.delegate = delegate;
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getHostname() {
|
||||
return delegate.getHostname();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getUser() {
|
||||
return delegate.getUser();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getPort() {
|
||||
return delegate.getPort();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getValue(String key) {
|
||||
if ("RequestTTY".equals(key)) {
|
||||
return "yes";
|
||||
}
|
||||
return delegate.getValue(key);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String[] getValues(String key) {
|
||||
if ("RequestTTY".equals(key)) {
|
||||
return new String[] { "yes" };
|
||||
}
|
||||
return delegate.getValues(key);
|
||||
}
|
||||
}
|
||||
|
||||
private class RequireTTYAlwaysConfigRepo implements ConfigRepository {
|
||||
private final ConfigRepository delegate;
|
||||
|
||||
public RequireTTYAlwaysConfigRepo(ConfigRepository delegate) {
|
||||
this.delegate = delegate;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Config getConfig(String host) {
|
||||
if (delegate == null) {
|
||||
return new RequireTTYAlwaysConfig(ConfigRepository.defaultConfig);
|
||||
}
|
||||
return new RequireTTYAlwaysConfig(delegate.getConfig(host));
|
||||
}
|
||||
}
|
||||
|
||||
private class GhidraUserInfo implements UserInfo {
|
||||
private String password;
|
||||
private String passphrase;
|
||||
|
||||
public String doPromptSecret(String prompt) {
|
||||
PasswordDialog dialog =
|
||||
new PasswordDialog(TITLE, "SSH", hostname, prompt, null, null);
|
||||
DockingWindowManager.showDialog(dialog);
|
||||
if (dialog.okWasPressed()) {
|
||||
return new String(dialog.getPassword());
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public String html(String message) {
|
||||
// TODO: I shouldn't have to do this. Why won't swing wrap?
|
||||
String wrapped = StringUtilities.wrapToWidth(message, WRAP_LEN);
|
||||
return "<html><pre>" + StringEscapeUtils.escapeHtml4(wrapped).replace("\n", "<br>");
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getPassphrase() {
|
||||
return passphrase;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getPassword() {
|
||||
return password;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean promptPassword(String message) {
|
||||
password = doPromptSecret(message);
|
||||
return password != null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean promptPassphrase(String message) {
|
||||
passphrase = doPromptSecret(message);
|
||||
return passphrase != null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean promptYesNo(String message) {
|
||||
return JOptionPane.showConfirmDialog(null, html(message), TITLE,
|
||||
JOptionPane.YES_NO_OPTION, JOptionPane.QUESTION_MESSAGE) == JOptionPane.YES_OPTION;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void showMessage(String message) {
|
||||
JOptionPane.showMessageDialog(null, html(message), TITLE,
|
||||
JOptionPane.INFORMATION_MESSAGE);
|
||||
}
|
||||
}
|
||||
|
||||
private String hostname = DEFAULT_HOSTNAME;
|
||||
private int port = DEFAULT_PORT;
|
||||
private String username = DEFAULT_USERNAME;
|
||||
private String configFile = DEFAULT_CONFIG_FILE;
|
||||
|
||||
private Session session;
|
||||
|
||||
public String getHostname() {
|
||||
return hostname;
|
||||
@ -59,81 +181,50 @@ public class GhidraSshPtyFactory implements PtyFactory {
|
||||
this.username = Objects.requireNonNull(username);
|
||||
}
|
||||
|
||||
public String getKeyFile() {
|
||||
return keyFile;
|
||||
public String getConfigFile() {
|
||||
return configFile;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the keyfile path, or empty for password authentication only
|
||||
*
|
||||
* @param keyFile the path
|
||||
*/
|
||||
public void setKeyFile(String keyFile) {
|
||||
this.keyFile = Objects.requireNonNull(keyFile);
|
||||
public void setConfigFile(String configFile) {
|
||||
this.configFile = configFile;
|
||||
}
|
||||
|
||||
public static char[] promptPassword(String hostname, String prompt) throws CancelledException {
|
||||
PasswordDialog dialog =
|
||||
new PasswordDialog("GDB via SSH", "SSH", hostname, prompt, null,
|
||||
"");
|
||||
DockingWindowManager.showDialog(dialog);
|
||||
if (dialog.okWasPressed()) {
|
||||
return dialog.getPassword();
|
||||
}
|
||||
throw new CancelledException();
|
||||
}
|
||||
|
||||
protected Connection connectAndAuthenticate() throws IOException {
|
||||
boolean success = false;
|
||||
File knownHostsFile = new File(System.getProperty("user.home") + "/.ssh/known_hosts");
|
||||
KnownHosts knownHosts = new KnownHosts();
|
||||
if (knownHostsFile.exists()) {
|
||||
knownHosts.addHostkeys(knownHostsFile);
|
||||
}
|
||||
|
||||
Connection sshConn = new Connection(hostname, port);
|
||||
protected Session connectAndAuthenticate() throws IOException {
|
||||
JSch jsch = new JSch();
|
||||
ConfigRepository configRepo = null;
|
||||
try {
|
||||
sshConn.connect(new GhidraSshHostKeyVerifier(knownHosts));
|
||||
if ("".equals(keyFile.trim())) {
|
||||
// TODO: Find an API that uses char[] so I can clear it!
|
||||
String password = new String(promptPassword(hostname, "Password for " + username));
|
||||
if (!sshConn.authenticateWithPassword(username, password)) {
|
||||
Msg.error(this, "SSH password authentication failed");
|
||||
throw new IOException("SSH password authentication failed");
|
||||
configRepo = OpenSSHConfig.parseFile(configFile);
|
||||
}
|
||||
catch (IOException e) {
|
||||
Msg.warn(this, "ssh config file " + configFile + " could not be parsed.");
|
||||
// I guess the config file doesn't exist. Just go on
|
||||
}
|
||||
else {
|
||||
File pemFile = new File(keyFile);
|
||||
if (!pemFile.canRead()) {
|
||||
throw new IOException("Key file " + keyFile +
|
||||
" cannot be read. Does it exist? Do you have permission?");
|
||||
}
|
||||
String password = new String(promptPassword(hostname, "Password for " + pemFile));
|
||||
if (!sshConn.authenticateWithPublicKey(username, pemFile, password)) {
|
||||
Msg.error(this, "SSH pukey authentication failed");
|
||||
throw new IOException("SSH pukey authentication failed");
|
||||
}
|
||||
}
|
||||
success = true;
|
||||
return sshConn;
|
||||
}
|
||||
catch (CancelledException e) {
|
||||
Msg.error(this, "SSH connection/authentication cancelled by user");
|
||||
throw new IOException("SSH connection/authentication cancelled by user", e);
|
||||
}
|
||||
finally {
|
||||
if (!success) {
|
||||
sshConn.close();
|
||||
jsch.setConfigRepository(new RequireTTYAlwaysConfigRepo(configRepo));
|
||||
|
||||
try {
|
||||
Session session =
|
||||
jsch.getSession(username.length() == 0 ? null : username, hostname, port);
|
||||
session.setUserInfo(new GhidraUserInfo());
|
||||
session.connect();
|
||||
return session;
|
||||
}
|
||||
catch (JSchException e) {
|
||||
Msg.error(this, "SSH connection error");
|
||||
throw new IOException("SSH connection error", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public SshPty openpty() throws IOException {
|
||||
if (sshConn == null || !sshConn.isAuthenticationComplete()) {
|
||||
sshConn = connectAndAuthenticate();
|
||||
if (session == null) {
|
||||
session = connectAndAuthenticate();
|
||||
}
|
||||
try {
|
||||
return new SshPty((ChannelExec) session.openChannel("exec"));
|
||||
}
|
||||
catch (JSchException e) {
|
||||
throw new IOException("SSH connection error", e);
|
||||
}
|
||||
return new SshPty(sshConn.openSession());
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -15,32 +15,36 @@
|
||||
*/
|
||||
package agent.gdb.pty.ssh;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.*;
|
||||
|
||||
import com.jcraft.jsch.*;
|
||||
|
||||
import agent.gdb.pty.*;
|
||||
import ch.ethz.ssh2.Session;
|
||||
|
||||
public class SshPty implements Pty {
|
||||
private final Session session;
|
||||
private final ChannelExec channel;
|
||||
private final OutputStream out;
|
||||
private final InputStream in;
|
||||
|
||||
public SshPty(Session session) throws IOException {
|
||||
this.session = session;
|
||||
session.requestDumbPTY();
|
||||
public SshPty(ChannelExec channel) throws JSchException, IOException {
|
||||
this.channel = channel;
|
||||
|
||||
out = channel.getOutputStream();
|
||||
in = channel.getInputStream();
|
||||
}
|
||||
|
||||
@Override
|
||||
public PtyParent getParent() {
|
||||
// TODO: Need I worry about stderr? I thought both pointed to the same tty....
|
||||
return new SshPtyParent(session.getStdin(), session.getStdout());
|
||||
return new SshPtyParent(out, in);
|
||||
}
|
||||
|
||||
@Override
|
||||
public PtyChild getChild() {
|
||||
return new SshPtyChild(session);
|
||||
return new SshPtyChild(channel, out, in);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() throws IOException {
|
||||
session.close();
|
||||
channel.disconnect();
|
||||
}
|
||||
}
|
||||
|
@ -16,23 +16,26 @@
|
||||
package agent.gdb.pty.ssh;
|
||||
|
||||
import java.io.*;
|
||||
import java.util.Arrays;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import javax.help.UnsupportedOperationException;
|
||||
|
||||
import com.jcraft.jsch.*;
|
||||
|
||||
import agent.gdb.pty.PtyChild;
|
||||
import ch.ethz.ssh2.Session;
|
||||
import ghidra.dbg.util.ShellUtils;
|
||||
import ghidra.util.Msg;
|
||||
|
||||
public class SshPtyChild extends SshPtyEndpoint implements PtyChild {
|
||||
private String name;
|
||||
private final Session session;
|
||||
private final ChannelExec channel;
|
||||
|
||||
public SshPtyChild(Session session) {
|
||||
super(null, null);
|
||||
this.session = session;
|
||||
private String name;
|
||||
|
||||
public SshPtyChild(ChannelExec channel, OutputStream outputStream, InputStream inputStream) {
|
||||
super(outputStream, inputStream);
|
||||
this.channel = channel;
|
||||
}
|
||||
|
||||
@Override
|
||||
@ -48,34 +51,37 @@ public class SshPtyChild extends SshPtyEndpoint implements PtyChild {
|
||||
.map(e -> e.getKey() + "=" + e.getValue())
|
||||
.collect(Collectors.joining(" ")) +
|
||||
" ";
|
||||
String cmdStr = Stream.of(args).collect(Collectors.joining(" "));
|
||||
String cmdStr = ShellUtils.generateLine(Arrays.asList(args));
|
||||
channel.setCommand(envStr + cmdStr);
|
||||
try {
|
||||
session.execCommand(envStr + cmdStr);
|
||||
channel.connect();
|
||||
}
|
||||
catch (Throwable t) {
|
||||
Msg.error(this, "Could not execute remote command: " + envStr + cmdStr, t);
|
||||
throw t;
|
||||
catch (JSchException e) {
|
||||
throw new IOException("SSH error", e);
|
||||
}
|
||||
return new SshPtySession(session);
|
||||
return new SshPtySession(channel);
|
||||
}
|
||||
|
||||
private String getTtyNameAndStartNullSession() throws IOException {
|
||||
// NB. Using [InputStream/Buffered]Reader will close my stream. Cannot do that.
|
||||
InputStream stdout = session.getStdout();
|
||||
// NB. UNIX sleep is only required to support integer durations
|
||||
session.execCommand(
|
||||
"sh -c 'tty && cltrc() { echo; } && trap ctrlc INT && while true; do sleep " +
|
||||
Integer.MAX_VALUE + "; done'",
|
||||
"UTF-8");
|
||||
channel.setCommand(
|
||||
("sh -c 'tty && ctrlc() { echo; } && trap ctrlc INT && while true; do sleep " +
|
||||
Integer.MAX_VALUE + "; done'"));
|
||||
try {
|
||||
channel.connect();
|
||||
}
|
||||
catch (JSchException e) {
|
||||
throw new IOException("SSH error", e);
|
||||
}
|
||||
byte[] buf = new byte[1024]; // Should be plenty
|
||||
for (int i = 0; i < 1024; i++) {
|
||||
int chr = stdout.read();
|
||||
int chr = inputStream.read();
|
||||
if (chr == '\n' || chr == -1) {
|
||||
return new String(buf, 0, i + 1).trim();
|
||||
return new String(buf, 0, i + 1, "UTF-8").trim();
|
||||
}
|
||||
buf[i] = (byte) chr;
|
||||
}
|
||||
throw new IOException("Remote tty name exceeds 1024 bytes?");
|
||||
throw new IOException("Expected pty name. Got " + new String(buf, 0, 1024, "UTF-8"));
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -21,13 +21,12 @@ import java.io.OutputStream;
|
||||
import agent.gdb.pty.PtyEndpoint;
|
||||
|
||||
public class SshPtyEndpoint implements PtyEndpoint {
|
||||
private final OutputStream outputStream;
|
||||
private final InputStream inputStream;
|
||||
protected final OutputStream outputStream;
|
||||
protected final InputStream inputStream;
|
||||
|
||||
public SshPtyEndpoint(OutputStream outputStream, InputStream inputStream) {
|
||||
this.outputStream = outputStream;
|
||||
this.inputStream = inputStream;
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -15,43 +15,30 @@
|
||||
*/
|
||||
package agent.gdb.pty.ssh;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InterruptedIOException;
|
||||
import com.jcraft.jsch.Channel;
|
||||
|
||||
import agent.gdb.pty.PtySession;
|
||||
import ch.ethz.ssh2.ChannelCondition;
|
||||
import ch.ethz.ssh2.Session;
|
||||
|
||||
public class SshPtySession implements PtySession {
|
||||
|
||||
private final Session session;
|
||||
private final Channel channel;
|
||||
|
||||
public SshPtySession(Session session) {
|
||||
this.session = session;
|
||||
public SshPtySession(Channel channel) {
|
||||
this.channel = channel;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Integer waitExited() throws InterruptedException {
|
||||
try {
|
||||
session.waitForCondition(ChannelCondition.EOF, 0);
|
||||
// Doesn't look like there's a clever way to wait. So do the spin sleep :(
|
||||
while (!channel.isEOF()) {
|
||||
Thread.sleep(1000);
|
||||
}
|
||||
// NB. May not be available
|
||||
return session.getExitStatus();
|
||||
}
|
||||
catch (InterruptedIOException e) {
|
||||
throw new InterruptedException();
|
||||
}
|
||||
catch (IOException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
return channel.getExitStatus();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void destroyForcibly() {
|
||||
/**
|
||||
* TODO: This is imperfect, since it terminates the whole SSH session, not just the pty
|
||||
* session. I don't think that's terribly critical for our use case, but we should adjust
|
||||
* the spec to account for this, or devise a better implementation.
|
||||
*/
|
||||
session.close();
|
||||
channel.disconnect();
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,44 @@
|
||||
/* ###
|
||||
* IP: GHIDRA
|
||||
*
|
||||
* 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
|
||||
*
|
||||
* http://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.
|
||||
*/
|
||||
package agent.gdb.model.ssh;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import agent.gdb.GdbOverSshDebuggerModelFactory;
|
||||
import agent.gdb.pty.ssh.SshPtyTest;
|
||||
import ghidra.dbg.DebuggerModelFactory;
|
||||
import ghidra.dbg.test.AbstractModelHost;
|
||||
import ghidra.util.exception.CancelledException;
|
||||
|
||||
public class SshJoinGdbModelHost extends AbstractModelHost {
|
||||
|
||||
@Override
|
||||
public DebuggerModelFactory getModelFactory() {
|
||||
return new GdbOverSshDebuggerModelFactory();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, Object> getFactoryOptions() {
|
||||
try {
|
||||
return Map.ofEntries(
|
||||
Map.entry("SSH username", SshPtyTest.promptUser()),
|
||||
Map.entry("Use existing session via new-ui", true));
|
||||
}
|
||||
catch (CancelledException e) {
|
||||
throw new AssertionError("Cancelled", e);
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,36 @@
|
||||
/* ###
|
||||
* IP: GHIDRA
|
||||
*
|
||||
* 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
|
||||
*
|
||||
* http://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.
|
||||
*/
|
||||
package agent.gdb.model.ssh;
|
||||
|
||||
import static org.junit.Assume.assumeFalse;
|
||||
|
||||
import org.junit.Before;
|
||||
|
||||
import agent.gdb.model.AbstractModelForGdbFactoryTest;
|
||||
import ghidra.util.SystemUtilities;
|
||||
|
||||
public class SshJoinModelForGdbFactoryTest extends AbstractModelForGdbFactoryTest {
|
||||
|
||||
@Before
|
||||
public void checkInteractive() {
|
||||
assumeFalse(SystemUtilities.isInTestingBatchMode());
|
||||
}
|
||||
|
||||
@Override
|
||||
public ModelHost modelHost() throws Throwable {
|
||||
return new SshJoinGdbModelHost();
|
||||
}
|
||||
}
|
@ -0,0 +1,29 @@
|
||||
/* ###
|
||||
* IP: GHIDRA
|
||||
*
|
||||
* 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
|
||||
*
|
||||
* http://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.
|
||||
*/
|
||||
package agent.gdb.model.ssh;
|
||||
|
||||
import org.junit.experimental.categories.Category;
|
||||
|
||||
import agent.gdb.model.AbstractModelForGdbSessionLauncherTest;
|
||||
import generic.test.category.NightlyCategory;
|
||||
|
||||
@Category(NightlyCategory.class) // this may actually be an @PortSensitive test
|
||||
public class SshJoinModelForGdbSessionLauncherTest extends AbstractModelForGdbSessionLauncherTest {
|
||||
@Override
|
||||
public ModelHost modelHost() throws Throwable {
|
||||
return new SshJoinGdbModelHost();
|
||||
}
|
||||
}
|
@ -1,195 +0,0 @@
|
||||
/* ###
|
||||
* IP: GHIDRA
|
||||
*
|
||||
* 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
|
||||
*
|
||||
* http://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.
|
||||
*/
|
||||
package agent.gdb.pty.ssh;
|
||||
|
||||
import static org.junit.Assume.assumeFalse;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
|
||||
import org.junit.Before;
|
||||
import org.junit.Test;
|
||||
|
||||
import ch.ethz.ssh2.*;
|
||||
import ghidra.app.script.AskDialog;
|
||||
import ghidra.test.AbstractGhidraHeadedIntegrationTest;
|
||||
import ghidra.util.SystemUtilities;
|
||||
import ghidra.util.exception.CancelledException;
|
||||
|
||||
public class SshExperimentsTest extends AbstractGhidraHeadedIntegrationTest {
|
||||
@Before
|
||||
public void checkInteractive() {
|
||||
assumeFalse(SystemUtilities.isInTestingBatchMode());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testExpExecCommandIsAsync()
|
||||
throws IOException, CancelledException, InterruptedException {
|
||||
Connection conn = new Connection("localhost");
|
||||
|
||||
conn.addConnectionMonitor(new ConnectionMonitor() {
|
||||
@Override
|
||||
public void connectionLost(Throwable reason) {
|
||||
System.err.println("Lost connection: " + reason);
|
||||
}
|
||||
});
|
||||
|
||||
conn.connect();
|
||||
|
||||
String user = SshPtyTest.promptUser();
|
||||
while (true) {
|
||||
char[] password =
|
||||
GhidraSshPtyFactory.promptPassword("localhost", "Password for " + user);
|
||||
boolean auth = conn.authenticateWithPassword(user, new String(password));
|
||||
if (auth) {
|
||||
break;
|
||||
}
|
||||
System.err.println("Authentication Failed");
|
||||
}
|
||||
|
||||
Session session = conn.openSession();
|
||||
System.err.println("PRE: signal=" + session.getExitSignal());
|
||||
|
||||
Thread thread = new Thread("reader") {
|
||||
@Override
|
||||
public void run() {
|
||||
InputStream stdout = session.getStdout();
|
||||
try {
|
||||
stdout.transferTo(System.out);
|
||||
}
|
||||
catch (IOException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
};
|
||||
thread.setDaemon(true);
|
||||
thread.start();
|
||||
|
||||
// Demonstrates that execCommand returns before the remote command exits
|
||||
System.err.println("Invoking sleep remotely");
|
||||
session.execCommand("sleep 10");
|
||||
System.err.println("Returned from execCommand");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testExpEOFImpliesCommandExited()
|
||||
throws IOException, CancelledException, InterruptedException {
|
||||
Connection conn = new Connection("localhost");
|
||||
|
||||
conn.addConnectionMonitor(new ConnectionMonitor() {
|
||||
@Override
|
||||
public void connectionLost(Throwable reason) {
|
||||
System.err.println("Lost connection: " + reason);
|
||||
}
|
||||
});
|
||||
|
||||
conn.connect();
|
||||
|
||||
AskDialog<String> dialog = new AskDialog<>("SSH", "Username:", AskDialog.STRING, "");
|
||||
if (dialog.isCanceled()) {
|
||||
throw new CancelledException();
|
||||
}
|
||||
String user = dialog.getValueAsString();
|
||||
while (true) {
|
||||
char[] password =
|
||||
GhidraSshPtyFactory.promptPassword("localhost", "Password for " + user);
|
||||
boolean auth = conn.authenticateWithPassword(user, new String(password));
|
||||
if (auth) {
|
||||
break;
|
||||
}
|
||||
System.err.println("Authentication Failed");
|
||||
}
|
||||
|
||||
Session session = conn.openSession();
|
||||
System.err.println("PRE: signal=" + session.getExitSignal());
|
||||
|
||||
Thread thread = new Thread("reader") {
|
||||
@Override
|
||||
public void run() {
|
||||
InputStream stdout = session.getStdout();
|
||||
try {
|
||||
stdout.transferTo(System.out);
|
||||
}
|
||||
catch (IOException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
};
|
||||
thread.setDaemon(true);
|
||||
thread.start();
|
||||
|
||||
// Demonstrates the ability to wait for the specific command
|
||||
System.err.println("Invoking sleep remotely");
|
||||
session.execCommand("sleep 3");
|
||||
session.waitForCondition(ChannelCondition.EOF, 0);
|
||||
System.err.println("Returned from waitForCondition");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testExpEnvWorks()
|
||||
throws IOException, CancelledException, InterruptedException {
|
||||
Connection conn = new Connection("localhost");
|
||||
|
||||
conn.addConnectionMonitor(new ConnectionMonitor() {
|
||||
@Override
|
||||
public void connectionLost(Throwable reason) {
|
||||
System.err.println("Lost connection: " + reason);
|
||||
}
|
||||
});
|
||||
|
||||
conn.connect();
|
||||
|
||||
AskDialog<String> dialog = new AskDialog<>("SSH", "Username:", AskDialog.STRING, "");
|
||||
if (dialog.isCanceled()) {
|
||||
throw new CancelledException();
|
||||
}
|
||||
String user = dialog.getValueAsString();
|
||||
while (true) {
|
||||
char[] password =
|
||||
GhidraSshPtyFactory.promptPassword("localhost", "Password for " + user);
|
||||
boolean auth = conn.authenticateWithPassword(user, new String(password));
|
||||
if (auth) {
|
||||
break;
|
||||
}
|
||||
System.err.println("Authentication Failed");
|
||||
}
|
||||
|
||||
Session session = conn.openSession();
|
||||
System.err.println("PRE: signal=" + session.getExitSignal());
|
||||
|
||||
Thread thread = new Thread("reader") {
|
||||
@Override
|
||||
public void run() {
|
||||
InputStream stdout = session.getStdout();
|
||||
try {
|
||||
stdout.transferTo(System.out);
|
||||
}
|
||||
catch (IOException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
};
|
||||
thread.setDaemon(true);
|
||||
thread.start();
|
||||
|
||||
// Demonstrates a syntax for specifying env.
|
||||
// I suspect this depends on the remote shell.
|
||||
System.err.println("Echoing...");
|
||||
session.execCommand("MY_DATA=test bash -c 'echo data:$MY_DATA:end'");
|
||||
session.waitForCondition(ChannelCondition.EOF, 0);
|
||||
System.err.println("Done");
|
||||
}
|
||||
}
|
@ -18,7 +18,7 @@ package agent.gdb.pty.ssh;
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assume.assumeFalse;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.*;
|
||||
|
||||
import org.junit.Before;
|
||||
import org.junit.Test;
|
||||
@ -36,9 +36,7 @@ public class SshPtyTest extends AbstractGhidraHeadedIntegrationTest {
|
||||
public void setupSshPtyTest() throws CancelledException {
|
||||
assumeFalse(SystemUtilities.isInTestingBatchMode());
|
||||
factory = new GhidraSshPtyFactory();
|
||||
factory.setHostname("localhost");
|
||||
factory.setUsername(promptUser());
|
||||
factory.setKeyFile("");
|
||||
}
|
||||
|
||||
public static String promptUser() throws CancelledException {
|
||||
@ -49,11 +47,41 @@ public class SshPtyTest extends AbstractGhidraHeadedIntegrationTest {
|
||||
return dialog.getValueAsString();
|
||||
}
|
||||
|
||||
public static class StreamPumper extends Thread {
|
||||
private final InputStream in;
|
||||
private final OutputStream out;
|
||||
|
||||
public StreamPumper(InputStream in, OutputStream out) {
|
||||
setDaemon(true);
|
||||
this.in = in;
|
||||
this.out = out;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
byte[] buf = new byte[1024];
|
||||
try {
|
||||
while (true) {
|
||||
int len = in.read(buf);
|
||||
if (len <= 0) {
|
||||
break;
|
||||
}
|
||||
out.write(buf, 0, len);
|
||||
}
|
||||
}
|
||||
catch (IOException e) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testSessionBash() throws IOException, InterruptedException {
|
||||
try (SshPty pty = factory.openpty()) {
|
||||
PtySession bash = pty.getChild().session(new String[] { "bash" }, null);
|
||||
pty.getParent().getOutputStream().write("exit\n".getBytes());
|
||||
OutputStream out = pty.getParent().getOutputStream();
|
||||
out.write("exit\n".getBytes("UTF-8"));
|
||||
out.flush();
|
||||
new StreamPumper(pty.getParent().getInputStream(), System.out).start();
|
||||
assertEquals(0, bash.waitExited().intValue());
|
||||
}
|
||||
}
|
||||
|
@ -158,7 +158,10 @@ public interface ConfigurableFactory<T> {
|
||||
if (codec == null) {
|
||||
continue;
|
||||
}
|
||||
property.setValue(codec.read(saveState, opt.getKey(), null));
|
||||
Object read = codec.read(saveState, opt.getKey(), null);
|
||||
if (read != null) {
|
||||
property.setValue(read);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -72,14 +72,13 @@ public class StringUtilities {
|
||||
public static final int UNICODE_REPLACEMENT = 0xFFFD;
|
||||
|
||||
/**
|
||||
* Unicode Byte Order Marks (BOM) characters are special characters in the Unicode
|
||||
* character space that signal endian-ness of the text.
|
||||
* Unicode Byte Order Marks (BOM) characters are special characters in the Unicode character
|
||||
* space that signal endian-ness of the text.
|
||||
* <p>
|
||||
* The value for the BigEndian version (0xFEFF) works for both 16 and 32 bit
|
||||
* character values.
|
||||
* The value for the BigEndian version (0xFEFF) works for both 16 and 32 bit character values.
|
||||
* <p>
|
||||
* There are separate values for Little Endian Byte Order Marks for 16 and 32 bit
|
||||
* characters because the 32 bit value is shifted left by 16 bits.
|
||||
* There are separate values for Little Endian Byte Order Marks for 16 and 32 bit characters
|
||||
* because the 32 bit value is shifted left by 16 bits.
|
||||
*/
|
||||
public static final int UNICODE_BE_BYTE_ORDER_MARK = 0xFEFF;
|
||||
public static final int UNICODE_LE16_BYTE_ORDER_MARK = 0x0____FFFE;
|
||||
@ -93,9 +92,9 @@ public class StringUtilities {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the given character is a special character.
|
||||
* For example a '\n' or '\\'. A value of 0 is not considered special for this purpose
|
||||
* as it is handled separately because it has more varied use cases.
|
||||
* Returns true if the given character is a special character. For example a '\n' or '\\'. A
|
||||
* value of 0 is not considered special for this purpose as it is handled separately because it
|
||||
* has more varied use cases.
|
||||
*
|
||||
* @param c the character
|
||||
* @return true if the given character is a special character
|
||||
@ -105,9 +104,9 @@ public class StringUtilities {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the given codePoint (ie. full unicode 32bit character) is a special character.
|
||||
* For example a '\n' or '\\'. A value of 0 is not considered special for this purpose
|
||||
* as it is handled separately because it has more varied use cases.
|
||||
* Returns true if the given codePoint (ie. full unicode 32bit character) is a special
|
||||
* character. For example a '\n' or '\\'. A value of 0 is not considered special for this
|
||||
* purpose as it is handled separately because it has more varied use cases.
|
||||
*
|
||||
* @param codePoint the codePoint (ie. character), see {@link String#codePointAt(int)}
|
||||
* @return true if the given character is a special character
|
||||
@ -119,9 +118,9 @@ public class StringUtilities {
|
||||
|
||||
/**
|
||||
* Determines if a string is enclosed in double quotes (ASCII 34 (0x22))
|
||||
*
|
||||
* @param str String to test for double-quote enclosure
|
||||
* @return True if the first and last characters are the double-quote character,
|
||||
* false otherwise
|
||||
* @return True if the first and last characters are the double-quote character, false otherwise
|
||||
*/
|
||||
public static boolean isDoubleQuoted(String str) {
|
||||
Matcher m = DOUBLE_QUOTED_STRING_PATTERN.matcher(str);
|
||||
@ -129,8 +128,9 @@ public class StringUtilities {
|
||||
}
|
||||
|
||||
/**
|
||||
* If the given string is enclosed in double quotes, extract the inner text.
|
||||
* Otherwise, return the given string unmodified.
|
||||
* If the given string is enclosed in double quotes, extract the inner text. Otherwise, return
|
||||
* the given string unmodified.
|
||||
*
|
||||
* @param str String to match and extract from
|
||||
* @return The inner text of a doubly-quoted string, or the original string if not
|
||||
* double-quoted.
|
||||
@ -145,6 +145,7 @@ public class StringUtilities {
|
||||
|
||||
/**
|
||||
* Returns true if the character is in displayable character range
|
||||
*
|
||||
* @param c the character
|
||||
* @return true if the character is in displayable character range
|
||||
*/
|
||||
@ -176,9 +177,9 @@ public class StringUtilities {
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the character into a string.
|
||||
* If the character is special, it will actually render the character.
|
||||
* For example, given '\n' the output would be "\\n".
|
||||
* Converts the character into a string. If the character is special, it will actually render
|
||||
* the character. For example, given '\n' the output would be "\\n".
|
||||
*
|
||||
* @param c the character to convert into a string
|
||||
* @return the converted character
|
||||
*/
|
||||
@ -192,6 +193,7 @@ public class StringUtilities {
|
||||
|
||||
/**
|
||||
* Returns a count of how many times the 'occur' char appears in the strings.
|
||||
*
|
||||
* @param string the string to look inside
|
||||
* @param occur the character to look for/
|
||||
* @return a count of how many times the 'occur' char appears in the strings
|
||||
@ -225,11 +227,11 @@ public class StringUtilities {
|
||||
* Generate a quoted string from US-ASCII character bytes assuming 1-byte chars.
|
||||
* <p>
|
||||
* Special characters and non-printable characters will be escaped using C character escape
|
||||
* conventions (e.g., \t, \n, \\uHHHH, etc.). If a character size other than 1-byte is
|
||||
* required the alternate form of this method should be used.
|
||||
* conventions (e.g., \t, \n, \\uHHHH, etc.). If a character size other than 1-byte is required
|
||||
* the alternate form of this method should be used.
|
||||
* <p>
|
||||
* The result string will be single quoted (ie. "'") if the input byte array is
|
||||
* 1 byte long, otherwise the result will be double-quoted ('"').
|
||||
* The result string will be single quoted (ie. "'") if the input byte array is 1 byte long,
|
||||
* otherwise the result will be double-quoted ('"').
|
||||
*
|
||||
* @param bytes character string bytes
|
||||
* @return escaped string for display use
|
||||
@ -254,8 +256,8 @@ public class StringUtilities {
|
||||
* Special characters and non-printable characters will be escaped using C character escape
|
||||
* conventions (e.g., \t, \n, \\uHHHH, etc.).
|
||||
* <p>
|
||||
* The result string will be single quoted (ie. "'") if the input byte array is
|
||||
* 1 character long (ie. charSize), otherwise the result will be double-quoted ('"').
|
||||
* The result string will be single quoted (ie. "'") if the input byte array is 1 character long
|
||||
* (ie. charSize), otherwise the result will be double-quoted ('"').
|
||||
*
|
||||
* @param bytes array of bytes
|
||||
* @param charSize number of bytes per character (1, 2, 4).
|
||||
@ -317,6 +319,7 @@ public class StringUtilities {
|
||||
* Returns true if the given string starts with <code>prefix</code> ignoring case.
|
||||
* <p>
|
||||
* Note: This method is equivalent to calling:
|
||||
*
|
||||
* <pre>
|
||||
* string.regionMatches(true, 0, prefix, 0, prefix.length());
|
||||
* </pre>
|
||||
@ -336,6 +339,7 @@ public class StringUtilities {
|
||||
* Returns true if the given string ends with <code>postfix</code>, ignoring case.
|
||||
* <p>
|
||||
* Note: This method is equivalent to calling:
|
||||
*
|
||||
* <pre>
|
||||
* int startIndex = string.length() - postfix.length();
|
||||
* string.regionMatches(true, startOffset, postfix, 0, postfix.length());
|
||||
@ -416,13 +420,14 @@ public class StringUtilities {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the index of the first whole word occurrence of the search word within
|
||||
* the given text. A whole word is defined as the character before and after the occurrence
|
||||
* must not be a JavaIdentifierPart.
|
||||
* Returns the index of the first whole word occurrence of the search word within the given
|
||||
* text. A whole word is defined as the character before and after the occurrence must not be a
|
||||
* JavaIdentifierPart.
|
||||
*
|
||||
* @param text the text to be searched.
|
||||
* @param searchWord the word to search for.
|
||||
* @return the index of the first whole word occurrence of the search word within
|
||||
* the given text, or -1 if not found.
|
||||
* @return the index of the first whole word occurrence of the search word within the given
|
||||
* text, or -1 if not found.
|
||||
*/
|
||||
public static int indexOfWord(String text, String searchWord) {
|
||||
int index = 0;
|
||||
@ -440,14 +445,15 @@ public class StringUtilities {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the substring within the text string starting at startIndex and having
|
||||
* the given length is a whole word. A whole word is defined as the character before and after
|
||||
* the occurrence must not be a JavaIdentifierPart.
|
||||
* Returns true if the substring within the text string starting at startIndex and having the
|
||||
* given length is a whole word. A whole word is defined as the character before and after the
|
||||
* occurrence must not be a JavaIdentifierPart.
|
||||
*
|
||||
* @param text the text containing the potential word.
|
||||
* @param startIndex the start index of the potential word within the text.
|
||||
* @param length the length of the potential word
|
||||
* @return true if the substring within the text string starting at startIndex and having
|
||||
* the given length is a whole word.
|
||||
* @return true if the substring within the text string starting at startIndex and having the
|
||||
* given length is a whole word.
|
||||
*/
|
||||
public static boolean isWholeWord(String text, int startIndex, int length) {
|
||||
if (startIndex > 0) {
|
||||
@ -466,11 +472,9 @@ public class StringUtilities {
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert tabs in the given string to spaces using
|
||||
* a default tab width of 8 spaces.
|
||||
* Convert tabs in the given string to spaces using a default tab width of 8 spaces.
|
||||
*
|
||||
* @param str
|
||||
* string containing tabs
|
||||
* @param str string containing tabs
|
||||
* @return string that has spaces for tabs
|
||||
*/
|
||||
public static String convertTabsToSpaces(String str) {
|
||||
@ -480,10 +484,8 @@ public class StringUtilities {
|
||||
/**
|
||||
* Convert tabs in the given string to spaces.
|
||||
*
|
||||
* @param str
|
||||
* string containing tabs
|
||||
* @param tabSize
|
||||
* length of the tab
|
||||
* @param str string containing tabs
|
||||
* @param tabSize length of the tab
|
||||
* @return string that has spaces for tabs
|
||||
*/
|
||||
public static String convertTabsToSpaces(String str, int tabSize) {
|
||||
@ -516,9 +518,8 @@ public class StringUtilities {
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a string containing multiple lines into an array where each
|
||||
* element in the array contains only a single line. The "\n" character is
|
||||
* used as the delimiter for lines.
|
||||
* Parses a string containing multiple lines into an array where each element in the array
|
||||
* contains only a single line. The "\n" character is used as the delimiter for lines.
|
||||
* <p>
|
||||
* This methods creates an empty string entry in the result array for initial and trailing
|
||||
* separator chars, as well as for consecutive separators.
|
||||
@ -532,9 +533,8 @@ public class StringUtilities {
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a string containing multiple lines into an array where each
|
||||
* element in the array contains only a single line. The "\n" character is
|
||||
* used as the delimiter for lines.
|
||||
* Parses a string containing multiple lines into an array where each element in the array
|
||||
* contains only a single line. The "\n" character is used as the delimiter for lines.
|
||||
*
|
||||
* @param s the string to parse
|
||||
* @param preserveTokens true signals to treat consecutive newlines as multiple lines; false
|
||||
@ -557,8 +557,7 @@ public class StringUtilities {
|
||||
}
|
||||
|
||||
/**
|
||||
* Enforces the given length upon the given string by trimming and then padding as
|
||||
* necessary.
|
||||
* Enforces the given length upon the given string by trimming and then padding as necessary.
|
||||
*
|
||||
* @param s the String to fix
|
||||
* @param pad the pad character to use if padding is required
|
||||
@ -572,9 +571,9 @@ public class StringUtilities {
|
||||
}
|
||||
|
||||
/**
|
||||
* Pads the source string to the specified length, using the filler string
|
||||
* as the pad. If length is negative, left justifies the string, appending
|
||||
* the filler; if length is positive, right justifies the source string.
|
||||
* Pads the source string to the specified length, using the filler string as the pad. If length
|
||||
* is negative, left justifies the string, appending the filler; if length is positive, right
|
||||
* justifies the source string.
|
||||
*
|
||||
* @param source the original string to pad.
|
||||
* @param filler the type of characters with which to pad
|
||||
@ -610,8 +609,8 @@ public class StringUtilities {
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits the given string into lines using <code>\n</code> and then pads each string
|
||||
* with the given pad string. Finally, the updated lines are formed into a single string.
|
||||
* Splits the given string into lines using <code>\n</code> and then pads each string with the
|
||||
* given pad string. Finally, the updated lines are formed into a single string.
|
||||
* <p>
|
||||
* This is useful for constructing complicated <code>toString()</code> representations.
|
||||
*
|
||||
@ -636,13 +635,11 @@ public class StringUtilities {
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the word at the given index in the given string. For example, the
|
||||
* string "The tree is green" and the index of 5, the result would be
|
||||
* "tree".
|
||||
* Finds the word at the given index in the given string. For example, the string "The tree is
|
||||
* green" and the index of 5, the result would be "tree".
|
||||
*
|
||||
* @param s the string to search
|
||||
* @param index
|
||||
* the index into the string to "seed" the word.
|
||||
* @param index the index into the string to "seed" the word.
|
||||
* @return String the word contained at the given index.
|
||||
*/
|
||||
public static String findWord(String s, int index) {
|
||||
@ -650,17 +647,16 @@ public class StringUtilities {
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the word at the given index in the given string; if the word
|
||||
* contains the given charToAllow, then allow it in the string. For example,
|
||||
* the string "The tree* is green" and the index of 5, charToAllow is '*',
|
||||
* then the result would be "tree*".
|
||||
* Finds the word at the given index in the given string; if the word contains the given
|
||||
* charToAllow, then allow it in the string. For example, the string "The tree* is green" and
|
||||
* the index of 5, charToAllow is '*', then the result would be "tree*".
|
||||
* <p>
|
||||
* If the search yields only whitespace, then the empty string will be returned.
|
||||
*
|
||||
* @param s the string to search
|
||||
* @param index the index into the string to "seed" the word.
|
||||
* @param charsToAllow chars that normally would be considered invalid, e.g., '*' so
|
||||
* that the word can be returned with the charToAllow
|
||||
* @param charsToAllow chars that normally would be considered invalid, e.g., '*' so that the
|
||||
* word can be returned with the charToAllow
|
||||
* @return String the word contained at the given index.
|
||||
*/
|
||||
public static String findWord(String s, int index, char[] charsToAllow) {
|
||||
@ -706,8 +702,8 @@ public class StringUtilities {
|
||||
}
|
||||
|
||||
/**
|
||||
* Loosely defined as a character that we would expected to be an normal ascii content meant
|
||||
* for consumption by a human. Also, provided allows chars will pass the test.
|
||||
* Loosely defined as a character that we would expected to be an normal ascii content meant for
|
||||
* consumption by a human. Also, provided allows chars will pass the test.
|
||||
*
|
||||
* @param c the char to check
|
||||
* @param charsToAllow characters that will cause this method to return true
|
||||
@ -730,8 +726,7 @@ public class StringUtilities {
|
||||
/**
|
||||
* Finds the starting position of the last word in the given string.
|
||||
*
|
||||
* @param s
|
||||
* the string to search
|
||||
* @param s the string to search
|
||||
* @return int the starting position of the last word, -1 if not found
|
||||
*/
|
||||
public static int findLastWordPosition(String s) {
|
||||
@ -758,7 +753,8 @@ public class StringUtilities {
|
||||
* <li>StringUtilities.getLastWord("/This/is/my/last/word/", "/") returns word</li>
|
||||
* <li>StringUtilities.getLastWord("This.is.my.last.word", ".") returns word</li>
|
||||
* <li>StringUtilities.getLastWord("/This/is/my/last/word/MyFile.java", ".") returns java</li>
|
||||
* <li>StringUtilities.getLastWord("/This/is/my/last/word/MyFile.java", "/") returns MyFile.java</li>
|
||||
* <li>StringUtilities.getLastWord("/This/is/my/last/word/MyFile.java", "/") returns
|
||||
* MyFile.java</li>
|
||||
* </ul>
|
||||
*
|
||||
* @param s the string from which to get the last word
|
||||
@ -778,9 +774,9 @@ public class StringUtilities {
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts an integer into a string.
|
||||
* For example, given an integer 0x41424344,
|
||||
* the returned string would be "ABCD".
|
||||
* Converts an integer into a string. For example, given an integer 0x41424344, the returned
|
||||
* string would be "ABCD".
|
||||
*
|
||||
* @param value the integer value
|
||||
* @return the converted string
|
||||
*/
|
||||
@ -796,10 +792,12 @@ public class StringUtilities {
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a JSON string for the given object using all of its fields. To control the
|
||||
* fields that are in the result string, see {@link Json}.
|
||||
* Creates a JSON string for the given object using all of its fields. To control the fields
|
||||
* that are in the result string, see {@link Json}.
|
||||
*
|
||||
* <P>
|
||||
* This is here as a marker to point users to the real {@link Json} String utility.
|
||||
*
|
||||
* <P>This is here as a marker to point users to the real {@link Json} String utility.
|
||||
* @param o the object for which to create a string
|
||||
* @return the string
|
||||
*/
|
||||
@ -818,12 +816,11 @@ public class StringUtilities {
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge two strings into one.
|
||||
* If one string contains the other, then the largest is returned.
|
||||
* If both strings are null then null is returned.
|
||||
* If both strings are empty, the empty string is returned.
|
||||
* If the original two strings differ, this adds the second string
|
||||
* to the first separated by a newline.
|
||||
* Merge two strings into one. If one string contains the other, then the largest is returned.
|
||||
* If both strings are null then null is returned. If both strings are empty, the empty string
|
||||
* is returned. If the original two strings differ, this adds the second string to the first
|
||||
* separated by a newline.
|
||||
*
|
||||
* @param string1 the first string
|
||||
* @param string2 the second string
|
||||
* @return the merged string
|
||||
@ -863,8 +860,9 @@ public class StringUtilities {
|
||||
* larger than the given length, then it will be trimmed to fit that length <b>after adding
|
||||
* ellipses</b>
|
||||
*
|
||||
* <p>The given <code>max</code> value must be at least 4. This is to ensure that, at a
|
||||
* minimum, we can display the {@value #ELLIPSES} plus one character.
|
||||
* <p>
|
||||
* The given <code>max</code> value must be at least 4. This is to ensure that, at a minimum, we
|
||||
* can display the {@value #ELLIPSES} plus one character.
|
||||
*
|
||||
* @param original The string to be limited
|
||||
* @param max The maximum number of characters to display (including ellipses, if trimmed).
|
||||
@ -892,15 +890,16 @@ public class StringUtilities {
|
||||
}
|
||||
|
||||
/**
|
||||
* Trims the given string the <code>max</code> number of characters. Ellipses will be
|
||||
* added to signal that content was removed. Thus, the actual number of removed characters
|
||||
* will be <code>(s.length() - max) + {@value StringUtilities#ELLIPSES}</code> length.
|
||||
* Trims the given string the <code>max</code> number of characters. Ellipses will be added to
|
||||
* signal that content was removed. Thus, the actual number of removed characters will be
|
||||
* <code>(s.length() - max) + {@value StringUtilities#ELLIPSES}</code> length.
|
||||
*
|
||||
* <p>If the string fits within the max, then the string will be returned.
|
||||
* <p>
|
||||
* If the string fits within the max, then the string will be returned.
|
||||
*
|
||||
* <p>The given <code>max</code> value must be at least 5. This is to ensure that, at a
|
||||
* minimum, we can display the {@value #ELLIPSES} plus one character from the front and
|
||||
* back of the string.
|
||||
* <p>
|
||||
* The given <code>max</code> value must be at least 5. This is to ensure that, at a minimum, we
|
||||
* can display the {@value #ELLIPSES} plus one character from the front and back of the string.
|
||||
*
|
||||
* @param s the string to trim
|
||||
* @param max the max number of characters to allow.
|
||||
@ -936,15 +935,13 @@ public class StringUtilities {
|
||||
}
|
||||
|
||||
/**
|
||||
* This method looks for all occurrences of successive asterisks (i.e.,
|
||||
* "**") and replace with a single asterisk, which is an equivalent usage in
|
||||
* Ghidra. This is necessary due to some symbol names which cause the
|
||||
* pattern matching process to become unusable. An example string that
|
||||
* This method looks for all occurrences of successive asterisks (i.e., "**") and replace with a
|
||||
* single asterisk, which is an equivalent usage in Ghidra. This is necessary due to some symbol
|
||||
* names which cause the pattern matching process to become unusable. An example string that
|
||||
* causes this problem is
|
||||
* "s_CLSID\{ADB880A6-D8FF-11CF-9377-00AA003B7A11}\InprocServer3_01001400".
|
||||
*
|
||||
* @param value
|
||||
* The string to be checked.
|
||||
* @param value The string to be checked.
|
||||
* @return The updated string.
|
||||
*/
|
||||
public static String fixMultipleAsterisks(String value) {
|
||||
@ -959,8 +956,8 @@ public class StringUtilities {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the character is OK to be contained inside C language string. That
|
||||
* is, the string should not be tokenized on this char.
|
||||
* Returns true if the character is OK to be contained inside C language string. That is, the
|
||||
* string should not be tokenized on this char.
|
||||
*
|
||||
* @param c the char
|
||||
* @return boolean true if it is allows in a C string
|
||||
@ -990,13 +987,13 @@ public class StringUtilities {
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces escaped characters in a string to corresponding control characters. For example
|
||||
* a string containing a backslash character followed by a 'n' character would be replaced
|
||||
* with a single line feed (0x0a) character. One use for this is to to allow users to
|
||||
* type strings in a text field and include control characters such as line feeds and tabs.
|
||||
* Replaces escaped characters in a string to corresponding control characters. For example a
|
||||
* string containing a backslash character followed by a 'n' character would be replaced with a
|
||||
* single line feed (0x0a) character. One use for this is to to allow users to type strings in a
|
||||
* text field and include control characters such as line feeds and tabs.
|
||||
*
|
||||
* The string that contains 'a','b','c', '\', 'n', 'd', '\', 'u', '0', '0', '0', '1', 'e' would become
|
||||
* 'a','b','c',0x0a,'d', 0x01, e"
|
||||
* The string that contains 'a','b','c', '\', 'n', 'd', '\', 'u', '0', '0', '0', '1', 'e' would
|
||||
* become 'a','b','c',0x0a,'d', 0x01, e"
|
||||
*
|
||||
* @param str The string to convert escape sequences to control characters.
|
||||
* @return a new string with escape sequences converted to control characters.
|
||||
@ -1033,8 +1030,8 @@ public class StringUtilities {
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to handle character escape sequence. Note that only a single Java character
|
||||
* will be produced which limits the range of valid character value.
|
||||
* Attempt to handle character escape sequence. Note that only a single Java character will be
|
||||
* produced which limits the range of valid character value.
|
||||
*
|
||||
* @param string string containing escape sequences
|
||||
* @param escapeSequence escape sequence (e.g., "\\u")
|
||||
@ -1042,8 +1039,7 @@ public class StringUtilities {
|
||||
* @param index current position within string
|
||||
* @param builder the builder into which the results will be added
|
||||
*
|
||||
* @return true if escape sequence processed and added a single character
|
||||
* to the builder.
|
||||
* @return true if escape sequence processed and added a single character to the builder.
|
||||
*/
|
||||
private static boolean handleEscapeSequence(String string, String escapeSequence, int hexLength,
|
||||
int index, StringBuilder builder) {
|
||||
@ -1069,12 +1065,12 @@ public class StringUtilities {
|
||||
|
||||
/**
|
||||
* Replaces known control characters in a string to corresponding escape sequences. For example
|
||||
* a string containing a line feed character would be converted to backslash character
|
||||
* followed by an 'n' character. One use for this is to display strings in a manner to
|
||||
* easily see the embedded control characters.
|
||||
* a string containing a line feed character would be converted to backslash character followed
|
||||
* by an 'n' character. One use for this is to display strings in a manner to easily see the
|
||||
* embedded control characters.
|
||||
*
|
||||
* The string that contains 'a','b','c',0x0a,'d', 0x01, 'e' would become
|
||||
* 'a','b','c', '\', 'n', 'd', 0x01, 'e'
|
||||
* The string that contains 'a','b','c',0x0a,'d', 0x01, 'e' would become 'a','b','c', '\', 'n',
|
||||
* 'd', 0x01, 'e'
|
||||
*
|
||||
* @param str The string to convert control characters to escape sequences
|
||||
* @return a new string with all the control characters converted to escape sequences.
|
||||
@ -1097,14 +1093,13 @@ public class StringUtilities {
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps known control characters to corresponding escape sequences. For example
|
||||
* a line feed character would be converted to backslash '\\' character
|
||||
* followed by an 'n' character. One use for this is to display strings in a manner to
|
||||
* easily see the embedded control characters.
|
||||
* Maps known control characters to corresponding escape sequences. For example a line feed
|
||||
* character would be converted to backslash '\\' character followed by an 'n' character. One
|
||||
* use for this is to display strings in a manner to easily see the embedded control characters.
|
||||
*
|
||||
* @param codePoint The character to convert to escape sequence string
|
||||
* @return a new string with equivalent to escape sequence, or original character (as
|
||||
* a string) if not in the control character mapping.
|
||||
* @return a new string with equivalent to escape sequence, or original character (as a string)
|
||||
* if not in the control character mapping.
|
||||
*/
|
||||
public static String convertCodePointToEscapeSequence(int codePoint) {
|
||||
int charCount = Character.charCount(codePoint);
|
||||
@ -1114,4 +1109,102 @@ public class StringUtilities {
|
||||
}
|
||||
return new String(new int[] { codePoint }, 0, 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* About the worst way to wrap lines ever
|
||||
*/
|
||||
public static class LineWrapper {
|
||||
enum Mode {
|
||||
INIT, WORD, SPACE;
|
||||
}
|
||||
|
||||
private final int width;
|
||||
private StringBuffer result = new StringBuffer();
|
||||
private int len = 0;
|
||||
|
||||
public LineWrapper(int width) {
|
||||
this.width = width;
|
||||
}
|
||||
|
||||
public LineWrapper append(CharSequence cs) {
|
||||
Mode mode = Mode.INIT;
|
||||
int b = 0;
|
||||
for (int f = 0; f < cs.length(); f++) {
|
||||
char c = cs.charAt(f);
|
||||
if (c == '\n') {
|
||||
if (mode == Mode.SPACE) {
|
||||
appendSpace(cs.subSequence(b, f));
|
||||
}
|
||||
else if (mode == Mode.WORD) {
|
||||
appendWord(cs.subSequence(b, f));
|
||||
}
|
||||
mode = Mode.INIT;
|
||||
appendLinesep();
|
||||
b = f + 1;
|
||||
}
|
||||
else if (Character.isWhitespace(c)) {
|
||||
if (mode == Mode.WORD) {
|
||||
appendWord(cs.subSequence(b, f));
|
||||
b = f;
|
||||
}
|
||||
mode = Mode.SPACE;
|
||||
}
|
||||
else {
|
||||
if (mode == Mode.SPACE) {
|
||||
appendSpace(cs.subSequence(b, f));
|
||||
b = f;
|
||||
}
|
||||
mode = Mode.WORD;
|
||||
}
|
||||
}
|
||||
if (mode == Mode.WORD) {
|
||||
appendWord(cs.subSequence(b, cs.length()));
|
||||
}
|
||||
else if (mode == Mode.SPACE) {
|
||||
appendSpace(cs.subSequence(b, cs.length()));
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
private void appendWord(CharSequence word) {
|
||||
len += word.length();
|
||||
result.append(word);
|
||||
}
|
||||
|
||||
private void appendSpace(CharSequence space) {
|
||||
if (len > width) {
|
||||
appendLinesep();
|
||||
len += space.length() - 1;
|
||||
result.append(space.subSequence(1, space.length()));
|
||||
}
|
||||
else {
|
||||
len += space.length();
|
||||
result.append(space);
|
||||
}
|
||||
}
|
||||
|
||||
private void appendLinesep() {
|
||||
result.append("\n");
|
||||
len = 0;
|
||||
}
|
||||
|
||||
public String finish() {
|
||||
return result.toString();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap the given string at whitespace to best fit within the given line width
|
||||
*
|
||||
* <p>
|
||||
* If it is not possible to fit a word in the given width, it will be put on a line by itself,
|
||||
* and that line will be allowed to exceed the given width.
|
||||
*
|
||||
* @param str the string to wrap
|
||||
* @param width the max width of each line, unless a single word exceeds it
|
||||
* @return
|
||||
*/
|
||||
public static String wrapToWidth(String str, int width) {
|
||||
return new LineWrapper(width).append(str).finish();
|
||||
}
|
||||
}
|
||||
|
30
licenses/JSch_License.txt
Normal file
30
licenses/JSch_License.txt
Normal file
@ -0,0 +1,30 @@
|
||||
JSch 0.0.* was released under the GNU LGPL license. Later, we have switched
|
||||
over to a BSD-style license.
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
Copyright (c) 2002-2015 Atsuhiko Yamanaka, JCraft,Inc.
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright notice,
|
||||
this list of conditions and the following disclaimer.
|
||||
|
||||
2. Redistributions in binary form must reproduce the above copyright
|
||||
notice, this list of conditions and the following disclaimer in
|
||||
the documentation and/or other materials provided with the distribution.
|
||||
|
||||
3. The names of the authors may not be used to endorse or promote products
|
||||
derived from this software without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED ``AS IS'' AND ANY EXPRESSED OR IMPLIED WARRANTIES,
|
||||
INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND
|
||||
FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL JCRAFT,
|
||||
INC. OR ANY CONTRIBUTORS TO THIS SOFTWARE BE LIABLE FOR ANY DIRECT, INDIRECT,
|
||||
INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
|
||||
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA,
|
||||
OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
|
||||
LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
|
||||
NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE,
|
||||
EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
@ -11,6 +11,7 @@ FAMFAMFAM_Mini_Icons_-_Public_Domain.txt||LICENSE||||END|
|
||||
GPL_2_With_Classpath_Exception.txt||LICENSE||||END|
|
||||
INRIA_License.txt||LICENSE||||END|
|
||||
JDOM_License.txt||LICENSE||||END|
|
||||
JSch_License.txt||LICENSE||||END|
|
||||
Jython_License.txt||LICENSE||||END|
|
||||
LGPL_2.1.txt||LICENSE||||END|
|
||||
LGPL_3.0.html||LICENSE||||END|
|
||||
|
Loading…
Reference in New Issue
Block a user