Feat: 파일 시스템 접근 및 디렉토리 탐색 명령어 구현

- FTPFileSystem 클래스 생성: 파일 시스템 추상화
  * 안드로이드 외부 저장소의 FTPServer 디렉토리를 루트로 설정
  * 경로 보안 검증 (루트 디렉토리 escape 방지)
  * 상대/절대 경로 지원
- FTPSession에 파일 시스템 통합
  * FTPFileSystem 인스턴스 추가
  * PWD 명령어를 파일 시스템 기반으로 수정
- 디렉토리 탐색 명령어 구현
  * CWD: 디렉토리 변경
  * CDUP: 상위 디렉토리로 이동
- 파일 목록 명령어 구현
  * LIST: 상세 파일 목록 (Unix 스타일)
  * NLST: 파일명만 나열
  * 임시로 제어 연결을 통해 전송 (데이터 연결은 Phase 4에서 구현)
- 파일/디렉토리 관리 명령어 구현
  * MKD: 디렉토리 생성
  * RMD: 디렉토리 삭제
  * DELE: 파일 삭제
  * SIZE: 파일 크기 조회

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-01 02:49:42 +09:00
parent f08d8d66d8
commit 7ec1073d14
2 changed files with 435 additions and 2 deletions

View File

@@ -0,0 +1,271 @@
package be.gyu.android.server.ftp;
import android.os.Environment;
import android.util.Log;
import java.io.File;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.Locale;
public class FTPFileSystem {
private static final String TAG = "FTPFileSystem";
private final File rootDirectory;
private File currentDirectory;
public FTPFileSystem() {
// Use external storage public directory as root
// In production, you might want to use app-specific directory or allow user to choose
File externalStorage = Environment.getExternalStorageDirectory();
this.rootDirectory = new File(externalStorage, "FTPServer");
// Create root directory if it doesn't exist
if (!rootDirectory.exists()) {
if (rootDirectory.mkdirs()) {
Log.i(TAG, "Root directory created: " + rootDirectory.getAbsolutePath());
} else {
Log.w(TAG, "Failed to create root directory, using external storage root");
this.rootDirectory = externalStorage;
}
}
this.currentDirectory = rootDirectory;
Log.d(TAG, "File system initialized. Root: " + rootDirectory.getAbsolutePath());
}
public String getCurrentPath() {
String relativePath = getRelativePath(currentDirectory);
return relativePath.isEmpty() ? "/" : "/" + relativePath;
}
public boolean changeDirectory(String path) {
File newDir;
if (path.startsWith("/")) {
// Absolute path
newDir = new File(rootDirectory, path.substring(1));
} else {
// Relative path
newDir = new File(currentDirectory, path);
}
try {
String canonicalPath = newDir.getCanonicalPath();
String rootPath = rootDirectory.getCanonicalPath();
// Security check: prevent escaping root directory
if (!canonicalPath.startsWith(rootPath)) {
Log.w(TAG, "Attempted to escape root directory: " + canonicalPath);
return false;
}
if (newDir.exists() && newDir.isDirectory()) {
currentDirectory = newDir;
Log.d(TAG, "Changed directory to: " + currentDirectory.getAbsolutePath());
return true;
} else {
Log.w(TAG, "Directory does not exist: " + newDir.getAbsolutePath());
return false;
}
} catch (Exception e) {
Log.e(TAG, "Error changing directory: " + e.getMessage());
return false;
}
}
public boolean changeToParentDirectory() {
File parent = currentDirectory.getParentFile();
if (parent == null) {
return false;
}
try {
String parentPath = parent.getCanonicalPath();
String rootPath = rootDirectory.getCanonicalPath();
// Cannot go above root directory
if (!parentPath.startsWith(rootPath)) {
return false;
}
currentDirectory = parent;
Log.d(TAG, "Changed to parent directory: " + currentDirectory.getAbsolutePath());
return true;
} catch (Exception e) {
Log.e(TAG, "Error changing to parent directory: " + e.getMessage());
return false;
}
}
public List<File> listFiles() {
File[] files = currentDirectory.listFiles();
List<File> fileList = new ArrayList<>();
if (files != null) {
for (File file : files) {
fileList.add(file);
}
}
return fileList;
}
public String formatFileList(boolean detailed) {
List<File> files = listFiles();
StringBuilder sb = new StringBuilder();
if (detailed) {
// LIST format: Unix-style detailed listing
SimpleDateFormat dateFormat = new SimpleDateFormat("MMM dd HH:mm", Locale.US);
for (File file : files) {
sb.append(formatDetailedFile(file, dateFormat)).append("\r\n");
}
} else {
// NLST format: names only
for (File file : files) {
sb.append(file.getName()).append("\r\n");
}
}
return sb.toString();
}
private String formatDetailedFile(File file, SimpleDateFormat dateFormat) {
// Format: drwxrwxrwx 1 owner group size date name
String permissions = file.isDirectory() ? "drwxr-xr-x" : "-rw-r--r--";
String owner = "ftp";
String group = "ftp";
long size = file.length();
String date = dateFormat.format(new Date(file.lastModified()));
String name = file.getName();
return String.format("%s %4d %-8s %-8s %10d %s %s",
permissions, 1, owner, group, size, date, name);
}
public boolean makeDirectory(String dirName) {
File newDir = new File(currentDirectory, dirName);
try {
String canonicalPath = newDir.getCanonicalPath();
String rootPath = rootDirectory.getCanonicalPath();
if (!canonicalPath.startsWith(rootPath)) {
return false;
}
if (newDir.mkdir()) {
Log.i(TAG, "Directory created: " + newDir.getAbsolutePath());
return true;
} else {
Log.w(TAG, "Failed to create directory: " + newDir.getAbsolutePath());
return false;
}
} catch (Exception e) {
Log.e(TAG, "Error creating directory: " + e.getMessage());
return false;
}
}
public boolean removeDirectory(String dirName) {
File dir = new File(currentDirectory, dirName);
try {
String canonicalPath = dir.getCanonicalPath();
String rootPath = rootDirectory.getCanonicalPath();
if (!canonicalPath.startsWith(rootPath)) {
return false;
}
if (dir.exists() && dir.isDirectory() && dir.delete()) {
Log.i(TAG, "Directory removed: " + dir.getAbsolutePath());
return true;
} else {
Log.w(TAG, "Failed to remove directory: " + dir.getAbsolutePath());
return false;
}
} catch (Exception e) {
Log.e(TAG, "Error removing directory: " + e.getMessage());
return false;
}
}
public boolean deleteFile(String fileName) {
File file = new File(currentDirectory, fileName);
try {
String canonicalPath = file.getCanonicalPath();
String rootPath = rootDirectory.getCanonicalPath();
if (!canonicalPath.startsWith(rootPath)) {
return false;
}
if (file.exists() && file.isFile() && file.delete()) {
Log.i(TAG, "File deleted: " + file.getAbsolutePath());
return true;
} else {
Log.w(TAG, "Failed to delete file: " + file.getAbsolutePath());
return false;
}
} catch (Exception e) {
Log.e(TAG, "Error deleting file: " + e.getMessage());
return false;
}
}
public File getFile(String fileName) {
File file = new File(currentDirectory, fileName);
try {
String canonicalPath = file.getCanonicalPath();
String rootPath = rootDirectory.getCanonicalPath();
if (!canonicalPath.startsWith(rootPath)) {
return null;
}
return file.exists() ? file : null;
} catch (Exception e) {
Log.e(TAG, "Error getting file: " + e.getMessage());
return null;
}
}
public long getFileSize(String fileName) {
File file = getFile(fileName);
return (file != null && file.isFile()) ? file.length() : -1;
}
private String getRelativePath(File file) {
try {
String filePath = file.getCanonicalPath();
String rootPath = rootDirectory.getCanonicalPath();
if (filePath.equals(rootPath)) {
return "";
} else if (filePath.startsWith(rootPath + File.separator)) {
return filePath.substring(rootPath.length() + 1).replace(File.separator, "/");
} else {
return "";
}
} catch (Exception e) {
Log.e(TAG, "Error getting relative path: " + e.getMessage());
return "";
}
}
public File getRootDirectory() {
return rootDirectory;
}
public File getCurrentDirectory() {
return currentDirectory;
}
}

View File

@@ -18,11 +18,12 @@ public class FTPSession implements Runnable {
private String username;
private boolean isAuthenticated = false;
private String currentDirectory = "/";
private boolean isRunning = true;
private FTPFileSystem fileSystem;
public FTPSession(Socket socket) {
this.controlSocket = socket;
this.fileSystem = new FTPFileSystem();
}
@Override
@@ -73,6 +74,30 @@ public class FTPSession implements Runnable {
case "PWD":
handlePwd();
break;
case "CWD":
handleCwd(argument);
break;
case "CDUP":
handleCdup();
break;
case "LIST":
handleList(argument);
break;
case "NLST":
handleNlst(argument);
break;
case "MKD":
handleMkd(argument);
break;
case "RMD":
handleRmd(argument);
break;
case "DELE":
handleDele(argument);
break;
case "SIZE":
handleSize(argument);
break;
case "NOOP":
handleNoop();
break;
@@ -113,7 +138,144 @@ public class FTPSession implements Runnable {
sendResponse(FTPResponse.NOT_LOGGED_IN, "Please login first");
return;
}
sendResponse(FTPResponse.PATHNAME_CREATED, "\"" + currentDirectory + "\" is current directory");
String path = fileSystem.getCurrentPath();
sendResponse(FTPResponse.PATHNAME_CREATED, "\"" + path + "\" is current directory");
}
private void handleCwd(String path) throws IOException {
if (!isAuthenticated) {
sendResponse(FTPResponse.NOT_LOGGED_IN, "Please login first");
return;
}
if (path.isEmpty()) {
sendResponse(FTPResponse.SYNTAX_ERROR_PARAMETERS, "No directory specified");
return;
}
if (fileSystem.changeDirectory(path)) {
sendResponse(FTPResponse.REQUESTED_FILE_ACTION_OK, "Directory changed to " + fileSystem.getCurrentPath());
} else {
sendResponse(FTPResponse.FILE_UNAVAILABLE, "Failed to change directory");
}
}
private void handleCdup() throws IOException {
if (!isAuthenticated) {
sendResponse(FTPResponse.NOT_LOGGED_IN, "Please login first");
return;
}
if (fileSystem.changeToParentDirectory()) {
sendResponse(FTPResponse.REQUESTED_FILE_ACTION_OK, "Directory changed to " + fileSystem.getCurrentPath());
} else {
sendResponse(FTPResponse.FILE_UNAVAILABLE, "Already at root directory");
}
}
private void handleList(String path) throws IOException {
if (!isAuthenticated) {
sendResponse(FTPResponse.NOT_LOGGED_IN, "Please login first");
return;
}
// For now, send file list directly without data connection
// Phase 4 will implement proper data connection
String fileList = fileSystem.formatFileList(true);
sendResponse(FTPResponse.FILE_STATUS_OK, "File list follows (inline, data connection not yet implemented)");
writer.write(fileList);
writer.flush();
sendResponse(FTPResponse.CLOSING_DATA_CONNECTION, "Directory send OK");
}
private void handleNlst(String path) throws IOException {
if (!isAuthenticated) {
sendResponse(FTPResponse.NOT_LOGGED_IN, "Please login first");
return;
}
// For now, send file list directly without data connection
String fileList = fileSystem.formatFileList(false);
sendResponse(FTPResponse.FILE_STATUS_OK, "File list follows (inline, data connection not yet implemented)");
writer.write(fileList);
writer.flush();
sendResponse(FTPResponse.CLOSING_DATA_CONNECTION, "Directory send OK");
}
private void handleMkd(String dirName) throws IOException {
if (!isAuthenticated) {
sendResponse(FTPResponse.NOT_LOGGED_IN, "Please login first");
return;
}
if (dirName.isEmpty()) {
sendResponse(FTPResponse.SYNTAX_ERROR_PARAMETERS, "No directory name specified");
return;
}
if (fileSystem.makeDirectory(dirName)) {
String newPath = fileSystem.getCurrentPath() + "/" + dirName;
sendResponse(FTPResponse.PATHNAME_CREATED, "\"" + newPath + "\" directory created");
} else {
sendResponse(FTPResponse.FILE_UNAVAILABLE, "Failed to create directory");
}
}
private void handleRmd(String dirName) throws IOException {
if (!isAuthenticated) {
sendResponse(FTPResponse.NOT_LOGGED_IN, "Please login first");
return;
}
if (dirName.isEmpty()) {
sendResponse(FTPResponse.SYNTAX_ERROR_PARAMETERS, "No directory name specified");
return;
}
if (fileSystem.removeDirectory(dirName)) {
sendResponse(FTPResponse.REQUESTED_FILE_ACTION_OK, "Directory removed");
} else {
sendResponse(FTPResponse.FILE_UNAVAILABLE, "Failed to remove directory");
}
}
private void handleDele(String fileName) throws IOException {
if (!isAuthenticated) {
sendResponse(FTPResponse.NOT_LOGGED_IN, "Please login first");
return;
}
if (fileName.isEmpty()) {
sendResponse(FTPResponse.SYNTAX_ERROR_PARAMETERS, "No file name specified");
return;
}
if (fileSystem.deleteFile(fileName)) {
sendResponse(FTPResponse.REQUESTED_FILE_ACTION_OK, "File deleted");
} else {
sendResponse(FTPResponse.FILE_UNAVAILABLE, "Failed to delete file");
}
}
private void handleSize(String fileName) throws IOException {
if (!isAuthenticated) {
sendResponse(FTPResponse.NOT_LOGGED_IN, "Please login first");
return;
}
if (fileName.isEmpty()) {
sendResponse(FTPResponse.SYNTAX_ERROR_PARAMETERS, "No file name specified");
return;
}
long size = fileSystem.getFileSize(fileName);
if (size >= 0) {
sendResponse(FTPResponse.FILE_STATUS, String.valueOf(size));
} else {
sendResponse(FTPResponse.FILE_UNAVAILABLE, "File not found");
}
}
private void handleNoop() throws IOException {