36 입출력·네트워크·시간·난수·암호
먼저 알아야 할 것
cap net·cap clock·cap random돌아보기
28장에서 files.read 의 답이 세 자리로 갈린 까닭은 무엇이었는가?
답. 값 하나가 “끝” 과 “실패” 두 뜻을 나르면, 읽기 실패가 “다 읽었다” 로 보고되기 때문이었다. 실제로 줄 세기 프로그램이 읽기 실패를 “0 줄” 이라는 성공으로 냈다. 이 장의 모듈들도 같은 원칙 — 뜻마다 한 자리, 권한마다 한 종류 — 을 따른다.
이 장의 필요성과 맥락
이 장이 끝나면
outbuf 로 출력을 버퍼에 모았다가 내보내고, 비우지 않으면 번역이 거절한다는 것을 확인한다. net 의 프로세스 안 연결 쌍으로 바이트를 주고받는 법, 결정적인 random.step 과 운영체제 엔트로피 random.bytes 를 가르는 이유, 단조 시계와 벽시계의 약속이 다르다는 것을 알게 된다. HTTP 요청 파서가 무엇을 거절하는지, 암호 모듈이 어떤 층으로 쌓여 있고 무엇이 아직 없는지도 보게 된다.이 장에서 답할 질문
- 예제가 실제 TCP 포트를 열지 않고 프로세스 안 쌍을 쓰는 이유는 무엇인가?
36.1 버퍼링 출력 — 비우기를 잊으면 번역이 거절한다#
examples/ch36/buffered.low
module buffered .
rem run: main
use outbuf .
proc main input out cap io . input al cap allocator . output u8 . effects io alloc .
do
let g option mut slice u8 be alloc_bytes al capacity 8 .
guard is_some g . else return 1 .
let buf mut slice u8 be some_value g .
var p owned outbuf.pending be outbuf.buf_open 1 .
let r1 result (owned outbuf.pending) outbuf.io_error be outbuf.buf_write out p buf "buffered " .
guard is_ok r1 . else return 2 .
var p2 owned outbuf.pending be ok_value r1 .
let r2 result (owned outbuf.pending) outbuf.io_error be outbuf.buf_write out p2 buf "output\n" .
guard is_ok r2 . else return 3 .
var p3 owned outbuf.pending be ok_value r2 .
let f result void outbuf.io_error be outbuf.buf_finish out p3 buf .
guard is_ok f . else return 4 .
return 0 .
end
실행 결과
$ lowentc --run main buffered.low
buffered output
main() = 0
outbuf.buf_open 1은 표준출력(1)에 내보낼 대기 중 출력을 만든다.owned outbuf.pending이다.outbuf.buf_write out p buf s는s를 버퍼에 모으고, 버퍼가 차면 내보낸 뒤 이어 쓴다. 대기 값을 받아 새 대기 값을 돌려준다(소유가 옮겨 다닌다).outbuf.buf_finish가 남은 바이트를 내보내고 대기 값을 끝낸다.
버퍼가 8 바이트뿐이라 “buffered “ 를 쓰는 도중에 한 번 비워진다. 출력은 같다. 한 바이트씩 write_out 하는 대신 모아서 내보내는 것이 버퍼링의 값이다. 마지막 buf_finish 를 잊으면 버퍼에 남은 바이트가 사라진다. 그래서 번역이 거절한다.
examples/ch36/unflushed.low
module unflushed .
rem expect: E-OWN-INCOMPLETE
use outbuf .
proc main input out cap io . input al cap allocator . output u8 . effects io alloc .
do
let g option mut slice u8 be alloc_bytes al capacity 64 .
guard is_some g . else return 1 .
let buf mut slice u8 be some_value g .
var p owned outbuf.pending be outbuf.buf_open 1 .
let r1 result (owned outbuf.pending) outbuf.io_error be outbuf.buf_write out p buf "lost?\n" .
guard is_ok r1 . else return 2 .
var p2 owned outbuf.pending be ok_value r1 .
return 0 .
end
실행 결과
$ lowentc --check unflushed.low
14:0 E-OWN-INCOMPLETE: this value is dropped automatically at the end of scope — but the program itself declares an op that takes this type `owned` BY VALUE and returns a `result`: finishing it CAN FAIL. An automatic drop is a RELEASE (total, non-suspending), and it has nowhere to hand you that failure — it would SWALLOW it. A fallible finish (flush/commit/close) is a COMPLETION and must be EXPLICIT: call it and handle the `result`. If you really mean to discard the value and its failure, say so with `drop`. RFC-0058
buf_finish 가 owned pending 을 받아 result 를 돌려주므로 pending 은 완결이 필요한 타입이다(19장). “마지막 줄이 안 찍혔다” 는 결함이 실행 전에 드러난다.
36.2 네트워크 — 핸들은 자원이다#
examples/ch36/loopback.low
module loopback .
rem run: main
use net .
proc main input k cap net . input al cap allocator . output u8 . effects io alloc .
do
let po option net.pair be net.pair_of k .
guard is_some po . else return 1 .
var p owned net.pair be some_value po .
let sent option u64 be net.send_all k (net.pair_a p) "ping" .
guard is_some sent . else return 2 .
let g option mut slice u8 be alloc_bytes al capacity 16 .
guard is_some g . else return 3 .
let got option u64 be net.recv_once k (net.pair_b p) (some_value g) .
let s result void net.net_error be net.shut_pair k p .
guard is_some got . else return 4 .
guard is_ok s . else return 5 .
return narrow u8 (some_value got) .
end
실행 결과
$ lowentc --run main loopback.low
main() = 4
net.pair_of 는 프로세스 안에서 서로 이어진 연결 한 쌍을 만든다. 한쪽(pair_a)으로 “ping” 을 보내고 다른 쪽(pair_b)에서 받아 4 바이트를 얻는다. net.shut_pair 로 쌍을 닫는다. 닫기는 실패할 수 있고 result 를 준다.
net 의 연결과 리스너는 files 의 핸들처럼 자원이다. 소켓을 닫지 않으면 서버가 오래 돌수록 기술자가 바닥난다. serve·dial·take 로 TCP 루프백 연결을 열 수 있고, 모든 op 이 cap net 을 첫 인자로 받는다. 이 모듈을 쓰지 않는 코드는 네트워크에 닿을 수 없다는 것이 머리에 보인다.
문. 예제가 실제 TCP 포트를 열지 않고 프로세스 안 쌍을 쓰는 이유는 무엇인가?
답. 예제는 VM 과 네이티브에서 똑같이 돌아 같은 답을 내야 한다. 실제 포트는 기계의 상태(이미 쓰이는 포트, 방화벽)에 따라 결과가 달라진다. 프로세스 안 쌍은 바깥 상태에 기대지 않으므로 결정적이다. 실제 서버 코드는 serve 와 take 로 같은 send_all·recv_once 를 쓴다.
36.3 난수 — 재현되는 열과 운영체제 엔트로피#
examples/ch36/dice.low
module dice .
rem run: roll 42
rem run: roll 42
rem run: roll 43
use random .
fn roll input seed u64 . output u64 .
do
let s1 u64 be random.step seed .
return add (random.below_biased s1 6) 1 .
end
실행 결과
$ lowentc --run roll dice.low 42
roll(42) = 3
$ lowentc --run roll dice.low 42
roll(42) = 3
$ lowentc --run roll dice.low 43
roll(43) = 5
random.step 은 시드에서 다음 값을 계산한다. 권한도 효과도 없는 순수한 fn 이다. 같은 시드 42 로 두 번 굴리면 두 번 다 3 이다. 시뮬레이션·시험·절차적 생성처럼 재현되어야 하는 난수는 이것을 쓴다.
운영체제의 엔트로피는 random.bytes k dst 로 얻고 cap random 을 받는다. 키와 논스처럼 예측되면 안 되는 난수는 이쪽이다. 두 일에 서로 다른 이름과 권한을 주는 까닭은, 하나로 합치면 재현되어야 할 시험이 운영체제 엔트로피에 기대게 되거나 반대로 키가 예측 가능한 열에서 나오기 때문이다. below_biased 는 이름대로 치우침이 있는 범위 줄이기다. 치우침이 문제인 자리에서는 쓰지 않는다는 뜻이 이름에 들어 있다.
36.4 시간 — 단조 시계와 벽시계#
examples/ch36/timing.low
module timing .
use clock .
proc elapsed_work input k cap clock . output u64 . effects none .
do
let start u64 be clock.now_ns k .
var i u64 be 0 .
var acc u64 be 0 .
while lt i 1000 . do
set acc (wrap_add acc i) .
set i (add i 1) .
end
return clock.since_ns k start .
end
실행 결과
$ lowentc --check timing.low
== check: ok ==
clock.now_ns 는 단조 시계다. 뒤로 가지 않으므로 두 번 재서 뺄 수 있다(since_ns). 절대 시각이 아니다. 벽시계(local_packed 와 year_of 따위)는 사람이 쓰는 날짜와 시각이고, 시간대 변경이나 시각 동기화로 뒤로 갈 수 있다. 경과 시간을 벽시계로 재면 음수가 나올 수 있다. 둘은 약속이 다르므로 op 이 다르다.
시계를 읽으면 같은 입력에 다른 답이 나오므로 결정성이 깨진다. 그래서 cap clock 이 필요하다. 그러나 바깥에 흔적을 남기지는 않으므로 효과는 none 이다(16장). 이 예제는 실행할 때마다 답이 달라서, 이 책의 검증 스크립트는 검사만 한다. sleep_ms 는 기다리므로 wait 효과다.
36.5 HTTP 요청 파서 — 알맹이는 거절이다#
examples/ch36/request_line.low
module request_line .
rem run: classify [71,69,84,32,47,32,72,84,84,80,47,49,46,49,13,10,13,10]
rem run: classify [66,82,69,87,32,47,32,72,84,84,80,47,49,46,49,13,10,13,10]
use http .
fn classify input req slice u8 . output u64 .
do
let m u64 be http.method_code req .
guard http.version_ok req . else return 99 .
return m .
end
실행 결과
$ lowentc --run classify request_line.low [71,69,84,32,47,32,72,84,84,80,47,49,46,49,13,10,13,10]
classify([71,69,84,32,47,32,72,84,84,80,47,49,46,49,13,10,13,10]) = 1
arg0 (written) = [71,69,84,32,47,32,72,84,84,80,47,49,46,49,13,10,13,10]
$ lowentc --run classify request_line.low [66,82,69,87,32,47,32,72,84,84,80,47,49,46,49,13,10,13,10]
classify([66,82,69,87,32,47,32,72,84,84,80,47,49,46,49,13,10,13,10]) = 0
arg0 (written) = [66,82,69,87,32,47,32,72,84,84,80,47,49,46,49,13,10,13,10]
http.method_code 는 요청의 메서드를 번호로(GET 은 1), http.version_ok 는 요청 줄의 판이 올바른지 답한다. 모르는 메서드 BREW 는 0 이다. 파서는 순수 계산이라 권한이 없다. 연결에서 바이트를 받는 일(net)과 그 바이트를 해석하는 일(http)이 다른 모듈이다.
이 파서의 설계 원칙은 애매한 입력을 받아 주지 않는 것이다. 요청 밀반입(request smuggling)은 서버와 프록시가 애매한 요청을 서로 다르게 받아들이는 자리에서 산다. 헤더 이름의 공백, 중복된 길이 헤더, 잘못된 줄바꿈 같은 것을 관대하게 받아 주면 그 틈이 생긴다. 이 모듈은 응답을 짓지 않는다 — 요청을 읽는 쪽이다.
흔한 오해. 관대한 파서가 사용자에게 친절하다
36.6 암호 — 쌓인 차례와 아직 없는 꼭대기#
암호 모듈들은 모두 순수 계산(L0)이고, TLS 1.3 을 향해 층으로 쌓여 있다.
| 층 | 모듈 |
|---|---|
| 유도 | hash(SHA-256) · hmac(HMAC-SHA256·HKDF) |
| 봉인(스위트 둘) | chacha·poly → aead · aes → gcm |
| 키 합의 | x25519 |
| 서명 검증 | bigint → rsa(PSS) · p256(ECDSA) · ed25519 |
| 서명 생성 | p256 → ecdsa(논스를 난수 없이 유도한다) |
| 키·인증서 꺼내기 | pem → der(공개키만 꺼내는 최소 파서) |
| 규격 계산 | tls13(키 스케줄·레코드·전사·Finished) |
| 핸드셰이크 | tlssrv(서버 핸드셰이크 양방향 + 응용 데이터 레코드 — 전송은 아직) |
표 36.1 — 암호 모듈이 쌓인 차례
각 모듈의 문서 첫머리가 혼자 쓰면 틀리는 방법을 경고한다. chacha 는 혼자서는 안전하지 않고(→ aead), aes 는 혼자 쓰면 대개 틀리며 (→ gcm), poly 의 키는 메시지마다 새것이어야 하고, gcm 의 논스를 되풀이하면 안 된다. x25519 의 작은 위수 점 거르기는 부르는 쪽의 일이다. 암호를 쓴다면 봉인은 aead 에서 시작한다.
그리고 꼭대기는 아직 없다. tlssrv 는 핸드셰이크를 모두 하지만 실제 소켓 위의 전송은 아직이고, der 은 인증서 체계(PKI)가 아니며 인증서는 밖에서 받는다. 없는 것을 있는 척하지 않는다는 원칙이 암호에서 가장 중요하다.
36.7 흔한 실수#
반례. outbuf.buf_write 에 넘긴 옛 대기 값으로 또 쓴다
examples/ch36/mistake_pendingmoved.low
module mistake_pendingmoved .
rem expect: E-OWN-MOVED
use outbuf .
proc main input out cap io . input al cap allocator . output u8 . effects io alloc .
do
let g option mut slice u8 be alloc_bytes al capacity 64 .
guard is_some g . else return 1 .
let buf mut slice u8 be some_value g .
var p owned outbuf.pending be outbuf.buf_open 1 .
let r1 result (owned outbuf.pending) outbuf.io_error be outbuf.buf_write out p buf "first\n" .
guard is_ok r1 . else return 2 .
rem ✘ `write` 가 가져간 옛 대기 값 `p` 로 또 쓴다 --- 새 대기 값은 `r1` 안에 있다
let r2 result (owned outbuf.pending) outbuf.io_error be outbuf.buf_write out p buf "second\n" .
drop r2 .
return 0 .
end
실행 결과
$ lowentc --check mistake_pendingmoved.low
mistake_pendingmoved.low:15:0 E-OWN-MOVED: this `owned` value was already MOVED (consumed) — using it again is use-after-move, which SPEC-004 §4.8 has always called a compile error and which nothing enforced. To keep using it, either CONSUME AND PUT IT BACK (`set <name> <new value>` re-initialises the place — that is how a handle threads through a loop), or borrow it LOCALLY with `ref h`. ☞ borrowing across an OP BOUNDARY is not lowered yet (E-IR-UNSUP says so at the call site), so `f (ref h)` is not a way out today
outbuf.buf_write 는 owned pending 을 받아 새 대기 값을 result 안에 돌려준다. 넘긴 p 는 이미 옮겨 갔으므로 다시 쓰면 E-OWN-MOVED 다. 대기 값이 옮겨 다니는 까닭은, 버퍼에 무엇이 남았는지를 아는 값이 언제나 하나뿐이어야 마지막 buf_finish 를 잊었는지 번역이 셀 수 있기 때문이다. 이 장의 buffered.low 처럼 p → p2 → p3 으로 이어 받는다.
반례. 같은 시드에서 두 번 굴린다
examples/ch36/mistake_sameseed.low
module mistake_sameseed .
rem run: two_rolls 2
use random .
rem ✘ 두 번 모두 같은 시드에서 굴린다 --- 두 주사위가 늘 같다
fn two_rolls input seed u64 . output u64 .
do
let a u64 be add (random.below_biased (random.step seed) 6) 1 .
let b u64 be add (random.below_biased (random.step seed) 6) 1 .
return add (mul a 10) b .
end
실행 결과
$ lowentc --run two_rolls mistake_sameseed.low 2
two_rolls(2) = 55
random.step 은 순수한 fn 이라 같은 입력에 늘 같은 답을 낸다. 두 굴림에 같은 seed 를 주면 두 주사위가 늘 같다(시드 2 에서 55). 전역 난수 상태가 없으므로 다음 상태를 이어 넘기는 일은 부르는 쪽의 몫이다.
examples/ch36/sameseed_fixed.low
module sameseed_fixed .
rem run: two_rolls 2
use random .
rem 다음 굴림은 앞 굴림이 낸 상태에서 시작한다
fn two_rolls input seed u64 . output u64 .
do
let s1 u64 be random.step seed .
let s2 u64 be random.step s1 .
let a u64 be add (random.below_biased s1 6) 1 .
let b u64 be add (random.below_biased s2 6) 1 .
return add (mul a 10) b .
end
실행 결과
$ lowentc --run two_rolls sameseed_fixed.low 2
two_rolls(2) = 56
고친 판은 첫 걸음이 낸 s1 에서 둘째 걸음을 시작해 56 을 낸다. 같은 시드로 부르면 언제나 같은 두 수가 나오는 것은 결함이 아니라 이 모듈의 약속이다.
흔한 오해. recv_once 는 상대가 보낸 것을 한 번에 다 받는다
examples/ch36/recv_partial.low
module recv_partial .
rem run: main
use net .
proc main input k cap net . input al cap allocator . output u8 . effects io alloc .
do
let po option net.pair be net.pair_of k .
guard is_some po . else return 1 .
var p owned net.pair be some_value po .
let sent option u64 be net.send_all k (net.pair_a p) "ping pong" .
guard is_some sent . else return 2 .
rem 아홉 바이트를 보냈지만 받는 버퍼는 네 칸이다 --- 한 번에 받는 것은 버퍼만큼
let g option mut slice u8 be alloc_bytes al capacity 4 .
guard is_some g . else return 3 .
let got option u64 be net.recv_once k (net.pair_b p) (some_value g) .
let s result void net.net_error be net.shut_pair k p .
drop s .
guard is_some got . else return 4 .
return narrow u8 (some_value got) .
end
실행 결과
$ lowentc --run main recv_partial.low
main() = 4
“ping pong” 아홉 바이트를 보냈지만 네 칸 버퍼로 한 번 받으면 4 다. 받기는 버퍼만큼, 그리고 그때 도착한 만큼만 준다. 스트림에는 메시지의 경계가 없다. 보낸 쪽이 두 번에 나눠 보냈어도 한 번에 올 수 있고, 한 번에 보냈어도 나눠 올 수 있다. 메시지 단위가 필요하면 길이를 앞에 붙이거나 구분자를 정하고, 다 모일 때까지 되풀이해 받는다.
36.8 이 장의 문법 한눈에#
| 모양 | 뜻 | 왜 이렇게 |
|---|---|---|
var p owned outbuf.pending be outbuf.buf_open 1 . | 표준출력으로 내보낼 대기 출력 | 잊으면 E-OWN-INCOMPLETE |
outbuf.buf_write out p buf s → result (owned pending) … | 모으고, 차면 내보내고, 새 대기 값을 준다 | 아는 값이 늘 하나 — 옛 값은 E-OWN-MOVED |
outbuf.buf_finish out p buf | 남은 바이트를 내보내고 끝낸다 | 완결 — 실패할 수 있다 |
net.pair_of k · net.send_all · net.recv_once · net.shut_pair | 연결 쌍 · 다 보내기 · 한 번 받기 · 닫기 | cap net 이 첫 인자 — 받기는 버퍼만큼 |
random.step seed · random.bytes k dst | 재현되는 다음 상태 · 운영체제 엔트로피(cap random) | 셈과 권위를 가른다 |
clock.now_ns k · clock.since_ns k start | 단조 시계 — 경과 시간 | 벽시계와 약속이 다르다 — cap clock, 효과 none |
http.method_code req · http.version_ok req | 요청 줄 해석(순수) | 애매한 입력을 거절한다 |
aead · gcm · x25519 · ed25519 · tls13 · tlssrv | 봉인 · 키 합의 · 서명 · TLS 계산 | 혼자 쓰면 틀리는 조각은 문서가 경고한다 |
표 36.2 — 입출력·네트워크·암호 모듈의 모양 — 모양 · 뜻 · 왜 이렇게 생겼나
복습 정리
outbuf 는 출력을 모았다가 내보내고, 비우지 않은 대기 값은 번역이 거절한다. net 의 연결은 cap net 으로 여는 자원이며 닫기가 실패할 수 있다. random.step 은 재현되는 순수 계산이고 random.bytes 는 cap random 으로 얻는 엔트로피다. 단조 시계와 벽시계는 약속이 다르다. http 파서는 애매한 입력을 거절하는 순수 계산이다. 암호 모듈은 유도·봉인·키 합의·서명·TLS 계산 순으로 쌓여 있고, 전송하는 TLS 는 아직 없다.