A Tiny `ls` Clone Written in Rust

Matthias Endler

Rust로 작성한 아주 작은 `ls` 클론

원문은 Matthias Endler님이 에 게재했습니다. 이 블로그 구독하기

쓸모없는 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 패턴은 각각 현재 사용자, 그룹, 그리고 그 밖의 사용자를 위해 세 번 반복됩니다.
  • 다음은 파일을 가리킬 때는 하드 링크 수, 디렉터리를 가리킬 때는 포함된 디렉터리 항목 수입니다. (Reference)
  • 소유자 이름
  • 그룹 이름
  • 파일의 바이트 수
  • 파일이 마지막으로 수정된 날짜
  • 마지막으로 경로 이름

더 자세한 정보는 대부분의 리눅스 배포판에서 사용하는 GNU coreutils와 macOS를 구동하는 Darwinls 매뉴얼 페이지를 읽어보시길 추천합니다.

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

아주 기본적인 ls를 Rust로 만들기

현재 디렉터리의 모든 파일을 단순히 출력하는 가장 기본적인 버전의 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 변수의 타입을 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 계열 사이에서도 차이가 있습니다.

위키백과에는 파일 권한에 대해 마주할 수 있는 몇 가지 확장 사항을 나열하고 있습니다.

이는 실제 구현할 때 고려해야 할 중요한 세부 사항이 많다는 것을 보여줍니다.

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

지금은 기본에만 충실하고 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

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

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

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

댓글