#
LD31D
Пишемо власний Which

Пишемо власний Which

На просторах інтернету знайшов дуже цікавий челендж Build Your Own Which, де просять написати власну консольну програму which.

Я впевнений, що створення власних реалізацій речей, якими ми користуємося щодня, покращує навички програмування і розуміння того, як працює світ навколо нас. Тому в планах періодично публікувати статті з поясненнями різних технологій та інструкціями, як написати власні інструменти.

Колись тут навіть зʼявиться стаття про написання власної операційної системи, але оскільки ведення блогу для мене ще новинка, я вирішив почати з чогось простішого.

Що таке Which?

which — це утиліта в Unix-подібних системах (Linux, macOS), яка показує, де розташований виконуваний файл команди, яку ти викликаєш у терміналі.

$ which cat
/bin/cat

В результаті ми отримали шлях до утиліти cat, знаючи його, ми можемо її виконати:

Саме так працюють shell-и: коли ми вводимо команду, вони шукають шлях до виконуваного файлу й запускають його.

Як which шукає шляхи?

В системі в нас є змінна оточення PATH, в якій вказані шляхи до директорій з файлами для виконання.

Подивитися її можна за допомогою echo:

$ echo $PATH
/bin:/usr/bin:/opt/homebrew/bin

На вашій системі результат буде значно більшим, я просто скоротив його для простоти розуміння. Як ми бачимо шляхи розділені двокрапками.

Коли ми виконуєму which він виконує такі дії:

  1. Отримує значення змінної оточення PATH
  2. Розділяє його (.split(":"))
  3. В кожній з директорій шукає виконуючий файл
  4. Виводить шлях або повідомлення, що не зміг нічого знайти

При виконанні якось команди поcлідосність дій така сама, але в кінці замість печаті шляху відбувається виконання програми.

Тому коли ми встановлюємо нову програму і її шляху не має в PATH, shell її не бачить.

Пишемо власний

Весь код ми будемо писати на Golang. Створимо файл main.go:

package main

import (
	"fmt"
	"os"
)

func main() {
	args := os.Args[1:]

	if len(args) == 0 {
		return
	}

	fmt.Println(args)
}

Тут ми отримуємо аргументи; якщо їх не передали - нічого не робимо.

Аргументи потрібно отримувати починаючи з другого елемента (os.Args[1:]), бо першим (os.Args[0]) є шлях до виконуваного файлу нашої утиліти.

Для запуску:

$ go run main.go cat
[cat]

Напишемо функцію для отримання PATH та розділення директорій по сепаратору ::

func getPathDirs() []string {
	pathEnv := os.Getenv("PATH")
	if pathEnv == "" {
		return []string{}
	}

	dirs := strings.Split(pathEnv, string(os.PathListSeparator))
	return dirs
}

Ця функція поверне нам []string з усіма директоріями, збереженими в PATH.

os.PathListSeparator - константа для розділення шляхів (в Unix - :). Вона має тип rune, тому її потрібно привести до string.

Тепер напишимо функію для пошуку шляху виконуючого файлу:

func getExecuteFilePath(binName string) string {
	pathDirs := getPathDirs()

	for _, dir := range pathDirs {
		fullPath := filepath.Join(dir, binName)

		if isExecutableFile(fullPath) {
			return fullPath
		}
	}

	return ""
}

Ця функція отримує результати з попередньої, проходить по всіх директоріях та шукає виконуваний файл. Якщо знайде - повертає шлях, інакше повертає порожній рядок.

Функція getExecuteFilePath використовує допоміжну isExecutableFile, яка перевіряє, чи файл можна виконати. Напишемо її:

func isExecutableFile(path string) bool {
	info, err := os.Stat(path)
	if err != nil {
		return false
	}

	if info.IsDir() {
		return false
	}

	mode := info.Mode()
	return mode&0o111 != 0
}

Тут ми перевіряємо, чи файл існує, чи це не директорія, і чи має файл біт виконання.

Unix Permission Bits

Детальніше розглянемо останню перевірку.

В Unix-подібних системах у кожного файлу в нас є 3 групи доступу:

  • owner
  • group
  • others

Кожна з цих груп може мати такі права:

  • r — доступ на читання
  • w — доступ на зміну
  • x — доступ на виконання

Ці права ми можемо побачити якщо виконаємо ls -l, вони записані в першій колонці:

-rwxr-xr--

Можемо поділити ці доступи на 3 частини:

rwx r-x r--

Перша частина показує, що ми як власник файлу можемо читати, змінювати та виконувати файл.

Друга частина це доступи для користувачів, які перебувають з нами в одній групі, вони можуть: читати та виконувати файл.

Остання частина - це доступи для всіх інших користувачів, які в данному випадку можуть лише читати файл.

Права також можна зобразити за допомогою восьмиричної системи числення. Більше про це в статті “Системи числення”.

БітЗначення
r4
w2
x1

Для запису декількох прав доступу ми можемо сумувати ці значення:

rw- = 4 + 2 = 6
r-x = 4 + 1 = 5
-wx = 2 + 1 = 3
rwx = 4 + 2 + 1 = 7

Для всіх груп користувачів ми можемо також записати ці права як octal числа:

rwx rwx rwx = 777
rwx r-x r-- = 754

Щоб можна було запускати файл, потрібно, щоб хоча б одна з цих трьох частин мала біт виконання:

--x --x --x

Тому ми використаємо побітну операцію AND (&):

permisions & 111

Це залишить лише ті біти які нам потрібні і якщо результат не буде нулем - то ми можемо запустити файл.

Останне, що нам потрібно зробти це додати шматок кода до main:

for _, name := range args {
    path := getExecuteFilePath(name)

    if path != "" {
        fmt.Println(path)
    } else {
        fmt.Printf("%s not found\n", name)
    }
}

Тепер ми можемо перевіти: Як ми бачимо це працює.

Резюме

Повний код:

package main

import (
	"fmt"
	"os"
	"path/filepath"
	"strings"
)

func getPathDirs() []string {
	pathEnv := os.Getenv("PATH")
	if pathEnv == "" {
		return []string{}
	}

	dirs := strings.Split(pathEnv, string(os.PathListSeparator))
	return dirs
}

func isExecutableFile(path string) bool {
	info, err := os.Stat(path)
	if err != nil {
		return false
	}

	if info.IsDir() {
		return false
	}

	mode := info.Mode()
	return mode&0o111 != 0
}

func getExecuteFilePath(binName string) string {
	pathDirs := getPathDirs()

	for _, dir := range pathDirs {
		fullPath := filepath.Join(dir, binName)

		if isExecutableFile(fullPath) {
			return fullPath
		}
	}

	return ""
}

func main() {
	args := os.Args[1:]

	if len(args) == 0 {
		fmt.Println("No arguments provided.")
		return
	}

	for _, name := range args {
		path := getExecuteFilePath(name)

		if path != "" {
			fmt.Println(path)
		} else {
			fmt.Printf("%s not found\n", name)
		}
	}
}

Сьогодні ми розібралися, як працює which та написали власну дуже примітивну реалізацію.

Також ми зрозуміли яку функцію виконує змінна оточення PATH та як працють права доступа Unix.

Рекомендую самостійно виконати всі шаги та внести покрашення.