A Tiny `ls` Clone Written in Rust

Matthias Endler

Rust로 만든 작은 `ls` 클론

쓸모없는 Unix 도구를 Rust로 다시 만들기 시리즈의 이번 글에서는 제가 가장 좋아하는 도구 중 하나인 ls를 다뤄보려고 합니다.

먼저 말씀드리자면, 이 코드를 여러분의 로컬 머신에서 ls 대신 사용하고 싶지는 않으실 겁니다(물론 할 수는 있습니다!). 곧 알게 되겠지만 ls는 속을 들여다보면 사실 꽤 강력한 도구입니다. 완전한 재구현을 하려는 것이 아니라, 커맨드라인에서 ls -l을 실행했을 때 기대할 수 있는 가장 기본적인 출력만 다루려고 합니다. 그 출력이 무엇인지 궁금하시죠? 잘 물어보셨습니다.

예상 출력

> ls -l
drwxr-xr-x 2 mendler  staff    13468 Feb  4 11:19 Top Secret
-rwxr--r-- 1 mendler  staff  6323935 Mar  8 21:56 Never Gonna Give You Up - Rick Astley.mp3
-rw-r--r-- 1 mendler  staff        0 Feb 18 23:55 Thoughts on Chess Boxing.doc
-rw-r--r-- 1 mendler  staff   380434 Dec 24 16:00 nobel-prize-speech.txt

출력은 환경에 따라 조금 다를 수 있지만, 일반적으로 주목할 만한 몇 가지가 있습니다. 왼쪽부터 오른쪽으로 살펴보면 다음과 같은 필드로 구성됩니다.

  • 맨 앞에 보이는 drwx 같은 문자열은 파일 권한(파일 모드라고도 합니다)입니다. d가 있으면 디렉터리라는 뜻이고, r은 읽기, w는 쓰기, x는 실행을 의미합니다. 이 rwx 패턴은 각각 현재 사용자, 그룹, 그리고 다른 사용자에 대해 세 번 반복됩니다.
  • 다음은 파일을 가리킬 때는 하드링크 수, 디렉터리를 가리킬 때는 포함된 디렉터리 항목의 수입니다. (참고)
  • 소유자 이름
  • 그룹 이름
  • 파일의 바이트 수
  • 파일이 마지막으로 수정된 날짜
  • 마지막으로 경로 이름

더 자세한 정보는 대부분의 리눅스 배포판에서 사용되는 GNU coreutilsls 매뉴얼 페이지와 MacOS를 구동하는 Darwin의 매뉴얼 페이지를 참고하시기 바랍니다.

휴, 이렇게 작은 도구치고는 정보가 참 많네요. 하지만 Rust로 포팅하는 게 그렇게 어렵지는 않겠죠? 바로 시작해 보겠습니다!

Rust로 만드는 아주 기본적인 ls

현재 디렉터리의 모든 파일을 출력하는 가장 기본적인 버전의 ls는 다음과 같습니다.

use std::fs;
use std::path::Path;
use std::error::Error;
use std::process;

fn main() {
	if let Err(ref e) = run(Path::new(".")) {
		println!("{}", e);
		process::exit(1);
	}
}

fn run(dir: &Path) -> Result<(), Box<Error>> {
	if dir.is_dir() {
		for entry in fs::read_dir(dir)? {
				let entry = entry?;
				let file_name = entry
						.file_name()
						.into_string()
						.or_else(|f| Err(format!("Invalid entry: {:?}", f)))?;
				println!("{}", file_name);
		}
	}
	Ok(())
}

이 코드는 공식 문서에서 그대로 가져올 수 있습니다. 실행하면 예상한 출력을 얻을 수 있습니다.

> cargo run
Cargo.lock
Cargo.toml
src
target

파일을 출력하고 종료합니다. 아주 간단하죠.

잠시 멈춰서 처음부터 직접 만든 첫 번째 작은 Unix 유틸리티를 자축해 보겠습니다. Pro Tip: cargo install로 바이너리를 설치하면 이제부터 다른 바이너리처럼 호출할 수 있습니다.

하지만 우리의 목표는 더 높으니 계속 진행해 보겠습니다.

디렉터리를 지정하는 매개변수 추가하기

보통 ls mydir이라고 입력하면 mydir 외의 다른 디렉터리가 아닌 바로 그 디렉터리의 파일 목록을 기대합니다. 우리 버전에도 같은 기능을 추가해 보겠습니다.

이를 위해 커맨드라인 매개변수를 받아야 합니다. 이럴 때 제가 즐겨 사용하는 Rust 크레이트 중 하나가 structopt입니다. 인수 파싱을 아주 쉽게 만들어 줍니다.

Cargo.toml에 추가해 주세요. (다음 명령어에는 cargo-edit이 필요합니다.)

cargo add structopt

이제 프로젝트에서 가져와 사용할 수 있습니다.

#[macro_use]
extern crate structopt;

// use std::...
use structopt::StructOpt;

#[derive(StructOpt, Debug)]
struct Opt {
	/// Output file
	#[structopt(default_value = ".", parse(from_os_str))]
	path: PathBuf,
}

fn main() {
	let opt = Opt::from_args();
	if let Err(ref e) = run(&opt.path) {
			println!("{}", e);
			process::exit(1);
	}
}

fn run(dir: &PathBuf) -> Result<(), Box<Error>> {
	// Same as before
}

Opt 구조체를 추가함으로써 커맨드라인 플래그, 입력 매개변수, 그리고 help 출력을 아주 쉽게 정의할 수 있습니다. 설정 옵션이 정말 많으니 프로젝트 홈페이지를 확인해 볼 가치가 있습니다.

또한 경로 변수의 타입을 Path에서 PathBuf로 변경했다는 점에 주목해 주세요. 차이점은 PathBuf는 내부 경로 문자열을 소유하고, Path단순히 그에 대한 참조를 제공한다는 것입니다. 이 관계는 String&str의 관계와 비슷합니다.

수정 시간 읽기

이제 메타데이터를 다뤄 보겠습니다. 먼저 파일에서 수정 시간을 가져와 보겠습니다. 문서를 빠르게 살펴보면 방법을 알 수 있습니다.

use std::fs;

let metadata = fs::metadata("foo.txt")?;

if let Ok(time) = metadata.modified() {
	println!("{:?}", time);
}

출력은 예상과 다를 수 있습니다. 우리는 시스템 시계의 측정값을 나타내는 SystemTime 객체를 받게 됩니다. 예를 들어 이 코드

println!("{:?}", SystemTime::now());
// Prints: SystemTime { tv_sec: 1520554933, tv_nsec: 610406401 }

하지만 우리가 원하는 형식은 다음과 같은 형태입니다.

Mar  9 01:24

다행히도 chrono라는 라이브러리가 이 형식을 읽어 우리가 원하는 사람이 읽기 쉬운 출력으로 변환해 줄 수 있습니다.

let current: DateTime<Local> = DateTime::from(SystemTime::now());
println!("{}", current.format("%_d %b %H:%M").to_string());

이 코드는 다음과 같이 출력합니다.

9 Mar 01:29

(네, 늦은 시간인 거 알고 있습니다.)

이제 그 지식을 바탕으로 파일 수정 시간을 읽어 보겠습니다.

cargo add chrono
use chrono::{DateTime, Local};

fn run(dir: &PathBuf) -> Result<(), Box<Error>> {
	if dir.is_dir() {
		for entry in fs::read_dir(dir)? {
			let entry = entry?;
			let file_name = ...

			let metadata = entry.metadata()?;
			let size = metadata.len();
			let modified: DateTime<Local> = DateTime::from(metadata.modified()?);

			println!(
				"{:>5} {} {}",
				size,
				modified.format("%_d %b %H:%M").to_string(),
				file_name
			);
		}
	}
	Ok(())
}

{:>5}가 좀 이상해 보일 수 있습니다. 이는 std::fmt에서 제공하는 포맷 지정자입니다. “이 필드를 공백 5칸으로 오른쪽 정렬하라”는 뜻으로, 우리보다 큰 형님 격인 ls -l이 하는 방식과 같습니다.

마찬가지로 metadata.len()으로 바이트 단위 크기를 가져왔습니다.

Unix 파일 권한은 복잡합니다

파일 권한을 읽는 것은 조금 더 까다롭습니다. rwx 표기법은 *BSD나 GNU/Linux 같은 Unix 계열에서는 매우 일반적이지만, 다른 많은 운영체제는 각자의 권한 관리 방식을 가지고 있습니다. 심지어 Unix 계열 사이에서도 차이가 있습니다.

위키백과에서는 파일 권한에 대해 마주칠 수 있는 몇 가지 확장 사항을 다음과 같이 소개합니다.

  • + (plus) 접미사는 추가 권한을 제어할 수 있는 접근 제어 목록이 있음을 나타냅니다.
  • . (dot) 접미사는 SELinux 컨텍스트가 존재함을 나타냅니다. 자세한 내용은 ls -Z 명령어로 확인할 수 있습니다.
  • @ 접미사는 확장 파일 속성이 존재함을 나타냅니다.

이만 봐도 실제 구현에서 고려해야 할 중요한 세부 사항이 얼마나 많은지 알 수 있습니다.

아주 기본적인 파일 모드 구현하기

일단은 기본에만 충실하고 rwx 파일 모드를 지원하는 플랫폼에 있다고 가정하겠습니다.

r, w, x 뒤에는 실제로는 8진수가 숨어 있습니다. 컴퓨터가 다루기에는 그게 더 쉽고, 많은 고수들은 기호보다 숫자로 입력하는 것을 더 선호하기도 합니다. 그 8진수 뒤의 규칙은 다음과 같습니다. chmod 매뉴얼 페이지에서 가져왔습니다.

	Modes may be absolute or symbolic.
	An absolute mode is an octal number constructed
	from the sum of one or more of the following values

	 0400    Allow read by owner.
	 0200    Allow write by owner.
	 0100    For files, allow execution by owner.
	 0040    Allow read by group members.
	 0020    Allow write by group members.
	 0010    For files, allow execution by group members.
	 0004    Allow read by others.
	 0002    Allow write by others.
	 0001    For files, allow execution by others.

예를 들어 파일 권한을 소유자는 읽고, 쓰고, 실행할 수 있고 다른 누구도 아무 것도 할 수 없도록 설정하려면 700(400 + 200 + 100)이 됩니다.

물론 이 숫자들은 70년대부터 동일하고 앞으로도 쉽게 바뀌지 않겠지만, 그렇다고 해서 파일 권한을 직접 그 값과 비교하는 것은 좋은 생각이 아닙니다. 호환성 때문이 아니더라도 코드의 가독성을 위해서, 그리고 매직 넘버를 피하기 위해서입니다.

따라서 우리는 이러한 매직 넘버에 대한 상수를 제공하는 libc 크레이트를 사용합니다. 앞서 언급했듯이 이러한 파일 권한은 Unix 전용이므로, 이를 위해 std::os::unix::fs::PermissionsExt;라는 Unix 전용 라이브러리를 가져와야 합니다.

extern crate libc;

// Examples:
// * `S_IRGRP` stands for "read permission for group",
// * `S_IXUSR` stands for "execution permission for user"
use libc::{S_IRGRP, S_IROTH, S_IRUSR, S_IWGRP, S_IWOTH, S_IWUSR, S_IXGRP, S_IXOTH, S_IXUSR};
use std::os::unix::fs::PermissionsExt;

이제 파일 권한을 다음과 같이 가져올 수 있습니다.

let metadata = entry.metadata()?;
let mode = metadata.permissions().mode();
parse_permissions(mode as u16);

parse_permissions()는 다음과 같이 정의된 작은 헬퍼 함수입니다.

fn parse_permissions(mode: u16) -> String {
	let user = triplet(mode, S_IRUSR, S_IWUSR, S_IXUSR);
	let group = triplet(mode, S_IRGRP, S_IWGRP, S_IXGRP);
	let other = triplet(mode, S_IROTH, S_IWOTH, S_IXOTH);
	[user, group, other].join("")
}

이 함수는 파일 모드를 u16(libc 상수가 u16이기 때문입니다)으로 받아 triplet을 호출합니다. 각 플래그인 read, write, execute에 대해 mode에 대해 이진 & 연산을 수행합니다. 출력은 가능한 모든 권한 패턴에 대해 철저하게 매칭됩니다.

fn triplet(mode: u16, read: u16, write: u16, execute: u16) -> String {
	match (mode & read, mode & write, mode & execute) {
		(0, 0, 0) => "---",
		(_, 0, 0) => "r--",
		(0, _, 0) => "-w-",
		(0, 0, _) => "--x",
		(_, 0, _) => "r-x",
		(_, _, 0) => "rw-",
		(0, _, _) => "-wx",
		(_, _, _) => "rwx",
	}.to_string()
}

마무리

최종 출력은 다음과 같습니다. 꽤 비슷하죠.

> cargo run
rw-r--r--     7  6 Mar 23:10 .gitignore
rw-r--r-- 15618  8 Mar 00:41 Cargo.lock
rw-r--r--   185  8 Mar 00:41 Cargo.toml
rwxr-xr-x   102  5 Mar 21:31 src
rwxr-xr-x   136  6 Mar 23:07 target

이게 전부입니다! Github에서 장난감 ls의 최종 버전을 찾을 수 있습니다. 아직 완전한 ls 대체품과는 거리가 멀지만, 적어도 내부 구조에 대해 한두 가지는 배웠습니다.

제대로 된 Rust로 작성된 ls 대체품을 찾고 계신다면 lsd를 확인해 보세요. 대신 같은 시리즈의 다른 블로그 글을 읽고 싶다면 A Little Story About the yes Unix Command를 확인해 보세요.

원문은 Matthias Endler님이 에 게재했습니다.

이 글은 muse-spark-1.2-contributor 모델을 사용해 번역했습니다.