From 9f21ec0f6b9aa9408f1dbfc5e877bc0b3a7141fb Mon Sep 17 00:00:00 2001 From: Simon Legner Date: Wed, 19 Mar 2025 14:52:07 +0100 Subject: [PATCH] Add local file implementation using java.nio.file.Path --- .../net/schmizz/sshj/xfer/FilePermission.java | 35 ++- .../java/net/schmizz/sshj/xfer/PathFile.java | 220 ++++++++++++++++++ .../net/schmizz/sshj/xfer/PathSpec.groovy | 54 +++++ 3 files changed, 303 insertions(+), 6 deletions(-) create mode 100644 src/main/java/net/schmizz/sshj/xfer/PathFile.java create mode 100644 src/test/groovy/net/schmizz/sshj/xfer/PathSpec.groovy diff --git a/src/main/java/net/schmizz/sshj/xfer/FilePermission.java b/src/main/java/net/schmizz/sshj/xfer/FilePermission.java index 760445e85..72dcb3653 100644 --- a/src/main/java/net/schmizz/sshj/xfer/FilePermission.java +++ b/src/main/java/net/schmizz/sshj/xfer/FilePermission.java @@ -15,9 +15,8 @@ */ package net.schmizz.sshj.xfer; -import java.util.HashSet; -import java.util.LinkedList; -import java.util.List; +import java.nio.file.attribute.PosixFilePermission; +import java.util.EnumSet; import java.util.Set; public enum FilePermission { @@ -72,11 +71,11 @@ public boolean isIn(int mask) { } public static Set fromMask(int mask) { - final List perms = new LinkedList(); + final Set perms = EnumSet.noneOf(FilePermission.class); for (FilePermission p : FilePermission.values()) if (p.isIn(mask)) perms.add(p); - return new HashSet(perms); + return perms; } public static int toMask(Set perms) { @@ -86,4 +85,28 @@ public static int toMask(Set perms) { return mask; } -} \ No newline at end of file + public static FilePermission of(PosixFilePermission posix) { + switch (posix) { + case GROUP_EXECUTE: + return GRP_X; + case GROUP_READ: + return GRP_R; + case GROUP_WRITE: + return GRP_W; + case OTHERS_EXECUTE: + return OTH_X; + case OTHERS_READ: + return OTH_R; + case OTHERS_WRITE: + return OTH_W; + case OWNER_EXECUTE: + return USR_X; + case OWNER_READ: + return USR_R; + case OWNER_WRITE: + return USR_W; + } + throw new IllegalArgumentException(String.valueOf(posix)); + } + +} diff --git a/src/main/java/net/schmizz/sshj/xfer/PathFile.java b/src/main/java/net/schmizz/sshj/xfer/PathFile.java new file mode 100644 index 000000000..8e5b089b0 --- /dev/null +++ b/src/main/java/net/schmizz/sshj/xfer/PathFile.java @@ -0,0 +1,220 @@ +/* + * Copyright (C)2009 - SSHJ Contributors + * + * 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 net.schmizz.sshj.xfer; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.UncheckedIOException; +import java.nio.file.FileAlreadyExistsException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardOpenOption; +import java.nio.file.attribute.FileTime; +import java.nio.file.attribute.PosixFilePermission; +import java.util.Arrays; +import java.util.Set; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +/** + * A file implementation using {@link Path} (NIO API). + */ +public class PathFile + implements LocalSourceFile, LocalDestFile { + + private final Path path; + + public PathFile(String path) { + this(Paths.get(path)); + } + + public PathFile(Path path) { + this.path = path; + } + + public Path getPath() { + return path; + } + + @Override + public String getName() { + return path.getFileName().toString(); + } + + @Override + public boolean isFile() { + return Files.isRegularFile(path); + } + + @Override + public boolean isDirectory() { + return Files.isDirectory(path); + } + + public boolean exists() { + return Files.exists(path); + } + + @Override + public long getLength() { + try { + return Files.size(path); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + @Override + public InputStream getInputStream() + throws IOException { + return Files.newInputStream(path); + } + + @Override + public OutputStream getOutputStream() + throws IOException { + return Files.newOutputStream(path); + } + + @Override + public OutputStream getOutputStream(boolean append) + throws IOException { + return Files.newOutputStream(path, StandardOpenOption.APPEND); + } + + @Override + public Iterable getChildren(final LocalFileFilter filter) + throws IOException { + try (Stream pathStream = Files.list(path)) { + return pathStream + .map(PathFile::new) + .filter(p -> filter == null || filter.accept(p)) + .collect(Collectors.toList()); + } + } + + @Override + public boolean providesAtimeMtime() { + return true; + } + + @Override + public long getLastAccessTime() + throws IOException { + return System.currentTimeMillis() / 1000; + } + + @Override + public long getLastModifiedTime() + throws IOException { + return Files.getLastModifiedTime(path).to(TimeUnit.SECONDS); + } + + @Override + public int getPermissions() + throws IOException { + Set permissions = Files.getPosixFilePermissions(path).stream().map(FilePermission::of).collect(Collectors.toSet()); + return FilePermission.toMask(permissions); + } + + @Override + public void setLastAccessedTime(long t) + throws IOException { + // ... + } + + @Override + public void setLastModifiedTime(long t) + throws IOException { + Files.setLastModifiedTime(path, FileTime.from(t, TimeUnit.SECONDS)); + } + + @Override + public void setPermissions(int perms) + throws IOException { + Set permissions = FilePermission.fromMask(perms); + Set posix = Arrays.stream(PosixFilePermission.values()) + .filter(p -> permissions.contains(FilePermission.of(p))) + .collect(Collectors.toSet()); + Files.setPosixFilePermissions(path, posix); + } + + @Override + public PathFile getChild(String name) { + Path resolved = path.resolve(name).normalize(); + if (!resolved.startsWith(path)) { + throw new IllegalArgumentException("Cannot traverse higher than " + path + " to get child " + name); + } + return new PathFile(resolved); + } + + @Override + public PathFile getTargetFile(String filename) + throws IOException { + PathFile f = this; + + if (f.isDirectory()) { + f = f.getChild(filename); + } + + try { + Files.createFile(f.getPath()); + } catch (FileAlreadyExistsException ignore) { + } + + return f; + } + + @Override + public PathFile getTargetDirectory(String dirname) + throws IOException { + PathFile f = this; + + if (f.exists()) { + if (f.isDirectory()) { + if (!f.getName().equals(dirname)) { + f = f.getChild(dirname); + } + } else { + throw new IOException(f + " - already exists as a file; directory required"); + } + } + + Files.createDirectories(f.getPath()); + + return f; + } + + @Override + public boolean equals(Object other) { + return (other instanceof PathFile) + && path.equals(((PathFile) other).path); + } + + @Override + public int hashCode() { + return path.hashCode(); + } + + @Override + public String toString() { + return path.toString(); + } + +} diff --git a/src/test/groovy/net/schmizz/sshj/xfer/PathSpec.groovy b/src/test/groovy/net/schmizz/sshj/xfer/PathSpec.groovy new file mode 100644 index 000000000..591b68071 --- /dev/null +++ b/src/test/groovy/net/schmizz/sshj/xfer/PathSpec.groovy @@ -0,0 +1,54 @@ +/* + * Copyright (C)2009 - SSHJ Contributors + * + * 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 net.schmizz.sshj.xfer + +import spock.lang.Specification + +class PathSpec extends Specification { + + def "should get child path"() { + given: + def file = new PathFile("foo") + + when: + def child = file.getChild("bar") + + then: + child.getName() == "bar" + } + + def "should not traverse higher than original path when getChild is called"() { + given: + def file = new PathFile("foo") + + when: + file.getChild("bar/.././foo/../../") + + then: + thrown(IllegalArgumentException.class) + } + + def "should ignore double slash (empty path component)"() { + given: + def file = new PathFile("foo") + + when: + def child = file.getChild("bar//etc/passwd") + + then: + child.getPath().toString().replace('\\', '/') endsWith "foo/bar/etc/passwd" + } +}