From a536148e41a59eec663c1843799457ca7e9c0b5d Mon Sep 17 00:00:00 2001 From: Florian Loitsch Date: Sun, 15 Feb 2026 15:02:14 +0100 Subject: [PATCH] Support more properties. --- src/tar.toit | 86 +++++++++++++++++++++++-- tests/properties-test.toit | 85 ++++++++++++++++++++++++ tests/properties-verification-test.toit | 57 ++++++++++++++++ tests/utils.toit | 80 +++++++++++++++++++---- 4 files changed, 289 insertions(+), 19 deletions(-) create mode 100644 tests/properties-test.toit create mode 100644 tests/properties-verification-test.toit diff --git a/src/tar.toit b/src/tar.toit index d231c68..bd2083c 100644 --- a/src/tar.toit +++ b/src/tar.toit @@ -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 /** @@ -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. @@ -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 @@ -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++: @@ -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] diff --git a/tests/properties-test.toit b/tests/properties-test.toit new file mode 100644 index 0000000..b21d528 --- /dev/null +++ b/tests/properties-test.toit @@ -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) diff --git a/tests/properties-verification-test.toit b/tests/properties-verification-test.toit new file mode 100644 index 0000000..f2db05c --- /dev/null +++ b/tests/properties-verification-test.toit @@ -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 diff --git a/tests/utils.toit b/tests/utils.toit index c57971e..f7e5db9 100644 --- a/tests/utils.toit +++ b/tests/utils.toit @@ -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/**/: +split-fields_ line/string -> List/**/: result := [] start-pos := 0 last-was-space := true @@ -85,22 +85,76 @@ split-fields line/string -> List/**/: 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/**/: 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