Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 80 additions & 6 deletions src/tar.toit
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,17 @@ A tar archiver.
Writes the given files into the writer in tar file format.
*/
class TarWriter:
static TYPE-REGULAR-FILE ::= '0'
static TYPE-LINK ::= '1'
static TYPE-SYMBOLIC-LINK ::= '2'
static TYPE-CHARACTER-DEVICE ::= '3'
static TYPE-BLOCK-DEVICE ::= '4'
static TYPE-DIRECTORY ::= '5'
static TYPE-FIFO ::= '6'

static TYPE-NORMAL_ ::= '0'
static TYPE-LONG-LINK_ ::= 'L'

writer_/io.Writer

/**
Expand All @@ -24,8 +35,27 @@ class TarWriter:
This function sets all file attributes to some default values. For example, the
modification date is set to 0 (epoch time).
*/
add file-name/string content/io.Data --permissions/int=((6 << 6) | (6 << 3) | 6) -> none:
add_ file-name content --permissions=permissions
add file-name/string content/io.Data
--permissions/int=((6 << 6) | (6 << 3) | 6)
--uid/int=0
--gid/int=0
--mtime/Time?=null
--type/int=TYPE-REGULAR-FILE
--user-name/string=""
--group-name/string=""
--device-major/int=0
--device-minor/int=0
-> none:
add_ file-name content
--permissions=permissions
--uid=uid
--gid=gid
--mtime=mtime
--type=type
--user-name=user-name
--group-name=group-name
--device-major=device-major
--device-minor=device-minor

/**
Closes the tar stream, but does not close the writer.
Expand All @@ -44,10 +74,30 @@ class TarWriter:
The $type parameter must be one of the constants below: $TYPE-NORMAL_ or
$TYPE-LONG-LINK_.
*/
add_ file-name/string content/io.Data --type/int=TYPE-NORMAL_ --permissions/int -> none:
add_ file-name/string content/io.Data
--permissions/int
--uid/int=0
--gid/int=0
--mtime/Time?=null
--type/int=TYPE-REGULAR-FILE
--user-name/string=""
--group-name/string=""
--device-major/int=0
--device-minor/int=0
-> none:
if file-name.size > 100:
// The file-name is encoded a separate "file".
add_ "././@LongLink" file-name --type=TYPE-LONG-LINK_ --permissions=permissions
add_ "././@LongLink" file-name
--permissions=permissions
--uid=uid
--gid=gid
--mtime=mtime
--type=TYPE-LONG-LINK_
--user-name=user-name
--group-name=group-name
--device-major=device-major
--device-minor=device-minor

file-name = file-name.copy 0 100

file-size := content.byte-size
Expand All @@ -56,15 +106,38 @@ class TarWriter:
permissions-in-octal/string := permissions.stringify 8
permissions-in-octal = permissions-in-octal.pad --left 7 '0'

uid-in-octal/string := uid.stringify 8
uid-in-octal = uid-in-octal.pad --left 7 '0'

gid-in-octal/string := gid.stringify 8
gid-in-octal = gid-in-octal.pad --left 7 '0'

mtime-val := mtime ? (mtime.ms-since-epoch / 1000) : 0
mtime-in-octal/string := mtime-val.stringify 8
mtime-in-octal = mtime-in-octal.pad --left 11 '0'

devmajor-in-octal/string := device-major.stringify 8
devmajor-in-octal = devmajor-in-octal.pad --left 7 '0'

devminor-in-octal/string := device-minor.stringify 8
devminor-in-octal = devminor-in-octal.pad --left 7 '0'

header := ByteArray 512
// See https://en.wikipedia.org/wiki/Tar_(computing)#File_format for the format.
header.replace 0 file-name
header.replace 100 permissions-in-octal
header.replace 108 uid-in-octal
header.replace 116 gid-in-octal
header.replace 124 file-size-in-octal
header.replace 136 mtime-in-octal
// The checksum is computed using spaces. Later it is replaced with the actual values.
header.replace 148 " "
header[156] = type
header.replace 257 "ustar "
header.replace 265 (limit-string_ user-name 32)
header.replace 297 (limit-string_ group-name 32)
header.replace 329 devmajor-in-octal
header.replace 337 devminor-in-octal

checksum := 0
for i := 0; i < 512; i++:
Expand All @@ -91,5 +164,6 @@ class TarWriter:
header[i] = '\0'
writer_.write header 0 missing

static TYPE-NORMAL_ ::= '0'
static TYPE-LONG-LINK_ ::= 'L'
static limit-string_ str/string limit/int -> string:
if str.size <= limit: return str
return str[..limit]
85 changes: 85 additions & 0 deletions tests/properties-test.toit
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
// Copyright (C) 2026 Toit contributors.
// Use of this source code is governed by a Zero-Clause BSD license that can
// be found in the tests/TESTS_LICENSE file.

import expect show *
import tar show *
import expect show *
import tar show *
import io

class MemoryWriter extends io.Writer:
buffer_/ByteArray := ByteArray 0

try-write_ data/io.Data from/int=0 to/int=data.byte-size -> int:
// If data is a string, convert to byte array.
bytes/ByteArray := ?
if data is string:
bytes = (data as string).to-byte-array
else:
bytes = data as ByteArray
// Append to buffer.
buffer_ = buffer_ + bytes[from..to]
return to - from

close:
// Do nothing.

bytes -> ByteArray:
return buffer_

main:
test-properties

test-properties:
writer := MemoryWriter
tar := TarWriter writer

mtime := Time.epoch + (Duration --s=1234567890)

tar.add "test.txt" "content"
--permissions=420 // 0644
--uid=1000
--gid=1000
--mtime=mtime
--type=TarWriter.TYPE-REGULAR-FILE
--user-name="user"
--group-name="group"
--device-major=1
--device-minor=2

tar.close

bytes := writer.bytes
// Header is first 512 bytes.
header := bytes[0..512]

// Helper to parse octal string from header.
parse-octal := : |offset length|
str-bytes := header[offset..offset+length]
// Find null terminator or take full length.
end := str-bytes.index-of 0
if end == -1: end = length
str := str-bytes[0..end].to-string
int.parse str --radix=8

// Helper to parse string from header.
parse-string := : |offset length|
str-bytes := header[offset..offset+length]
end := str-bytes.index-of 0
if end == -1: end = length
str-bytes[0..end].to-string

// Verify fields.
expect-equals "test.txt" (parse-string.call 0 100)
expect-equals 420 (parse-octal.call 100 8)
expect-equals 1000 (parse-octal.call 108 8)
expect-equals 1000 (parse-octal.call 116 8)
expect-equals 7 (parse-octal.call 124 12) // "content".size = 7
expect-equals 1234567890 (parse-octal.call 136 12)
expect-equals TarWriter.TYPE-REGULAR-FILE header[156]
expect-equals "ustar " (parse-string.call 257 8)
expect-equals "user" (parse-string.call 265 32)
expect-equals "group" (parse-string.call 297 32)
expect-equals 1 (parse-octal.call 329 8)
expect-equals 2 (parse-octal.call 337 8)
57 changes: 57 additions & 0 deletions tests/properties-verification-test.toit
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import expect show *
import tar show *
import .utils

main:
test-properties-verification

test-properties-verification:
// We use a generator that writes a tar file to the provided writer.
entries := list-with-tar-bin: |writer|
tar := TarWriter writer

// File 1: RW-R--R-- (644).
tar.add "test-props.txt" "content"
--permissions=0b110_100_100 // 644.
--uid=1234
--gid=5678
--user-name="testuser"
--group-name="testgroup"
--mtime=(Time.epoch + (Duration --s=1000000000)) // 2001-09-09.

// File 2: OWXRWXRWX (777) but with some mask usually, let's try 755.
tar.add "executable.sh" "echo hello"
--permissions=0b111_101_101 // 755.
--uid=1000
--gid=1000

tar.close

expect-equals 2 entries.size

// Verify File 1
entry1 := entries[0]
expect-equals "test-props.txt" entry1.name
expect-equals 7 entry1.size
expect-equals 0b110_100_100 entry1.permissions
// Note: System tar might display IDs if user/group names don't map to existing users,
// or it might display the names from the header.
// The 'utils.toit' listing uses 'tar -tvf' which usually prefers names if available in header (ustar).
// Let's check what we get. If it fails we might need to adjust expectation to allow IDs.
// But since we wrote "testuser" in the header, 'tar' should show it.
expect-equals "testuser" entry1.owner
expect-equals "testgroup" entry1.group
// 2001-09-09.
// Linux: 2001-09-09 03:46 (timezone dependent?).
// Mac: Sep 9 2001 (or similar).
// Just check it contains 2001 or Sep.
print "Entry 1 mtime: $entry1.mtime"
expect (entry1.mtime.contains "2001")

// Verify File 2.
entry2 := entries[1]
expect-equals "executable.sh" entry2.name
expect-equals 10 entry2.size
expect-equals 0b111_101_101 entry2.permissions
expect-equals "1000" entry2.owner
expect-equals "1000" entry2.group
80 changes: 67 additions & 13 deletions tests/utils.toit
Original file line number Diff line number Diff line change
Expand Up @@ -65,11 +65,11 @@ Returns the concatenated contents of all extracted files.
*/
extract [generator]:
return run-tar
"x" // extract
"-PO" // P for absolute paths, to stdout
"x" // extract.
"-PO" // P for absolute paths, to stdout.
generator

split-fields line/string -> List/*<string>*/:
split-fields_ line/string -> List/*<string>*/:
result := []
start-pos := 0
last-was-space := true
Expand All @@ -85,22 +85,76 @@ split-fields line/string -> List/*<string>*/:
class TarEntry:
name/string
size/int
permissions/string
permissions/int
owner/string
group/string
mtime/string

constructor --.name --.size --.permissions:
constructor --.name --.size --.permissions --.owner --.group --.mtime:

parse-permissions_ str/string -> int:
res := 0
// Expected format: -rwxrwxrwx (10 chars).
if str.size < 10: return 0

// User.
if str[1] == 'r': res |= 0b100_000_000
if str[2] == 'w': res |= 0b010_000_000
if str[3] == 'x': res |= 0b001_000_000

// Group.
if str[4] == 'r': res |= 0b000_100_000
if str[5] == 'w': res |= 0b000_010_000
if str[6] == 'x': res |= 0b000_001_000

// Other.
if str[7] == 'r': res |= 0b000_000_100
if str[8] == 'w': res |= 0b000_000_010
if str[9] == 'x': res |= 0b000_000_001

return res

list-with-tar-bin [generator] -> List/*<TarEntry>*/:
listing := inspect-with-tar-bin generator
lines := (listing.trim --right "\n").split "\n"
return lines.map: |line|
// A line looks something like:
// Linux: -rw-rw-r-- 0/0 5 1970-01-01 01:00 /foo
// Mac: -rw-rw-r-- 0 0 0 5 Jan 1 1970 /foo
permissions-index := 0
name-index := system.platform == system.PLATFORM-MACOS ? 8 : 5
size-index := system.platform == system.PLATFORM-MACOS ? 4 : 2
components := split-fields line
// Linux: -rw-rw-r-- 1000/1000 5 1970-01-01 01:00 /foo
// Mac: -rw-rw-r-- 0 1000 1000 5 Jan 1 1970 /foo
// Mac (recent): -rw-rw-r-- 0 1000 1000 5 Jan 1 01:00 /foo
components := split-fields_ line

name-index := 0
size-index := 0
mtime-string := ""
user := ""
group := ""

permissions := parse-permissions_ components[0]

if system.platform == system.PLATFORM-MACOS:
name-index = 8
size-index = 4
user = components[2]
group = components[3]
// Date is at 5, 6, 7 (e.g., "Jan", "1", "1970" or "Jan", "1", "01:00").
mtime-string = "$components[5] $components[6] $components[7]"
else:
name-index = 5
size-index = 2
parts := components[1].split "/"
user = parts[0]
group = parts[1]
// Date is at 3, 4 (e.g., "1970-01-01", "01:00")
mtime-string = "$components[3] $components[4]"

file-name := components[name-index]
size := int.parse components[size-index]
permissions := components[permissions-index]
TarEntry --name=file-name --size=size --permissions=permissions

TarEntry
--name=file-name
--size=size
--permissions=permissions
--owner=user
--group=group
--mtime=mtime-string