I Tried to Write a Program That Could Still Run Unmodified in 2056

от автора

The idea started while looking through an old C utility. The program was older than some developers I know, yet most of its source still looked perfectly understandable. At the same time, I have much newer projects that are already annoying to rebuild because one package has disappeared, another requires an obsolete runtime, and some build plugin expects an environment that no longer exists.

That felt backwards. A program written decades ago should be harder to recover than something created recently, but in practice the opposite can happen. The old source may need nothing more than a compiler, while the newer project may depend on a package registry, a specific runtime, several build tools and hundreds of exact dependency versions.

So I decided to try a small experiment. The goal was not to predict computers in 2056. That would obviously be impossible. Instead, the goal was to create a program today that had a reasonable chance of remaining understandable, compilable and usable thirty years later without modifying its source code.

No frozen Docker image. No virtual machine containing an ancient operating system. No carefully preserved development environment. Just source code, a compiler and an operating system.

The program itself would be deliberately boring: an append-only log that stores timestamped text records and can read them back. That simplicity was intentional. If a program is supposed to survive thirty years, perhaps it should not begin its life with a dependency installation command that downloads half the Internet.

What does unmodified actually mean?

My first version of the challenge was much stricter: compile an executable in 2026 and make that exact binary run in 2056. It sounds like the purest form of compatibility, but the more I thought about it, the less useful it became.

An executable depends on much more than its source code. The CPU instruction set has to remain usable. The executable format has to remain supported. The kernel ABI must still understand the binary. If the program uses dynamic linking, the loader and libraries must still provide compatible interfaces. Linux has traditionally been conservative about breaking userspace, which helps a lot, but thirty years is still a long bet.

There is also a more obvious problem. A perfectly preserved x86-64 executable is not very helpful if the ordinary machines of 2056 happen to use another architecture. Keeping an old binary alive may actually preserve the wrong thing.

So I changed the requirement. The source code must remain unchanged, but recompilation is allowed. A future machine should be able to receive the original project directory, execute a simple compiler command and produce a working program for whatever architecture exists at that point.

That decision immediately changed almost everything else. A framework became a long-term dependency. A package manager became a long-term dependency. A code generator became a long-term dependency. Even a sophisticated build system became something that future developers would have to reconstruct before they could compile a program consisting of a few hundred lines.

Eventually the dependency list became almost embarrassingly short: C11, POSIX, a filesystem and a C compiler. Even C11 is not especially important here. Most of the program could be written in a much older dialect, but stdint.h is extremely useful when defining an on-disk format with explicitly sized integers.

The entire build process became this:

cc -std=c11 -O2 -Wall -Wextra -pedantic longlog.c -o longlog

There is no configure script, no CMake project, no generated source, no package lock file and no network access during compilation. If that command looks boring today, I think that is a good sign. The interesting part was how quickly this changed the way I looked at dependencies. Every convenience library started to look less like convenience and more like a promise somebody else would have to keep until 2056.

The file format was more dangerous than the source code

Keeping the source compilable is only half the problem. Imagine that the program builds perfectly in 2056 but cannot read the files it created in 2026. Technically the source survived, but practically the software failed.

My first implementation made the classic mistake of treating memory layout as a storage format. I had a C structure containing a length, a timestamp and some metadata, and the tempting solution was to write that structure directly to disk.

That lasted about five minutes.

C structures are not portable serialization formats. Compilers may insert padding. Different architectures can use different byte order. Some native types have implementation-dependent sizes. Alignment rules can change. The moment sizeof becomes part of a persistent format, the implementation starts leaking into the data.

So the file format had to stop looking like memory.

The final format begins with an eight-byte magic identifier. Every record then contains a four-byte payload length, a four-byte CRC32 checksum, an eight-byte timestamp and the payload itself. Every integer is encoded explicitly in little-endian order. Nothing is written using native structure layout.

There are no pointers, compiler-specific bit fields or machine-sized integers. The maximum payload length is also fixed at one megabyte, because a corrupted length field should not convince a future reader to allocate a ridiculous amount of memory.

The CRC32 is not intended to protect against malicious modification. It is simply a cheap way to distinguish a valid record from random damage or a partial write.

The most important rule is that records are independent. If the final record is incomplete because the program crashed while writing it, every earlier record is still valid. There is no global footer, no mandatory file-wide index and no central record counter that must agree with everything else.

This sounds primitive, but the more I worked on the format, the more I liked that property. A damaged tail should not turn years of previous data into an unreadable file.

The complete program

The implementation ended up fitting comfortably into one C file. That was not a code-golf goal. I simply kept removing things that were not essential to the experiment.

#define _POSIX_C_SOURCE 200809L#include <errno.h>#include <fcntl.h>#include <stdint.h>#include <stdio.h>#include <stdlib.h>#include <string.h>#include <sys/stat.h>#include <sys/types.h>#include <time.h>#include <unistd.h>static const unsigned char MAGIC[8] = {    'L', '3', '0', 'L', 'O', 'G', '0', '1'};static void die(const char *msg){    perror(msg);    exit(1);}static uint32_t crc32(const unsigned char *p, size_t n){    uint32_t crc = 0xffffffffu;    for (size_t i = 0; i < n; ++i) {        crc ^= p[i];        for (int bit = 0; bit < 8; ++bit) {            uint32_t mask = 0u - (crc & 1u);            crc = (crc >> 1) ^ (0xedb88320u & mask);        }    }    return ~crc;}static void put_u32le(unsigned char out[4], uint32_t v){    out[0] = (unsigned char)v;    out[1] = (unsigned char)(v >> 8);    out[2] = (unsigned char)(v >> 16);    out[3] = (unsigned char)(v >> 24);}static void put_u64le(unsigned char out[8], uint64_t v){    for (int i = 0; i < 8; ++i)        out[i] = (unsigned char)(v >> (i * 8));}static uint32_t get_u32le(const unsigned char in[4]){    return (uint32_t)in[0]        | ((uint32_t)in[1] << 8)        | ((uint32_t)in[2] << 16)        | ((uint32_t)in[3] << 24);}static uint64_t get_u64le(const unsigned char in[8]){    uint64_t v = 0;    for (int i = 7; i >= 0; --i)        v = (v << 8) | in[i];    return v;}static int write_all(int fd, const void *buf, size_t n){    const unsigned char *p = buf;    while (n > 0) {        ssize_t written = write(fd, p, n);        if (written < 0) {            if (errno == EINTR)                continue;            return -1;        }        p += (size_t)written;        n -= (size_t)written;    }    return 0;}static int read_exact(int fd, void *buf, size_t n){    unsigned char *p = buf;    size_t done = 0;    while (done < n) {        ssize_t r = read(fd, p + done, n - done);        if (r == 0)            return done == 0 ? 0 : -2;        if (r < 0) {            if (errno == EINTR)                continue;            return -1;        }        done += (size_t)r;    }    return 1;}static int open_log(const char *path, int flags){    int fd = open(path, flags, 0644);    if (fd < 0)        die("open");    struct stat st;    if (fstat(fd, &st) < 0)        die("fstat");    if (st.st_size == 0) {        if (!(flags & O_WRONLY) && !(flags & O_RDWR)) {            fprintf(stderr, "empty log\n");            exit(2);        }        if (write_all(fd, MAGIC, sizeof MAGIC) < 0)            die("write magic");        if (fsync(fd) < 0)            die("fsync magic");    } else {        unsigned char got[8];        if (lseek(fd, 0, SEEK_SET) < 0)            die("lseek");        int result = read_exact(fd, got, sizeof got);        if (result != 1 ||            memcmp(got, MAGIC, sizeof MAGIC) != 0) {            fprintf(stderr, "not an L30LOG01 file\n");            exit(2);        }    }    return fd;}static void cmd_add(const char *path, const char *text){    size_t len = strlen(text);    if (len > 1024 * 1024) {        fprintf(stderr, "record too large\n");        exit(2);    }    int fd = open_log(path, O_CREAT | O_RDWR);    if (lseek(fd, 0, SEEK_END) < 0)        die("lseek end");    unsigned char header[16];    put_u32le(header + 0, (uint32_t)len);    put_u32le(        header + 4,        crc32((const unsigned char *)text, len)    );    put_u64le(header + 8, (uint64_t)time(NULL));    if (write_all(fd, header, sizeof header) < 0)        die("write header");    if (write_all(fd, text, len) < 0)        die("write payload");    if (fsync(fd) < 0)        die("fsync record");    if (close(fd) < 0)        die("close");}static void cmd_list(const char *path){    int fd = open_log(path, O_RDONLY);    if (lseek(fd, (off_t)sizeof MAGIC, SEEK_SET) < 0)        die("lseek records");    for (uint64_t seq = 0;; ++seq) {        unsigned char header[16];        int result = read_exact(fd, header, sizeof header);        if (result == 0)            break;        if (result == -2) {            fprintf(                stderr,                "truncated record header at record %llu\n",                (unsigned long long)seq            );            break;        }        if (result < 0)            die("read header");        uint32_t len = get_u32le(header + 0);        uint32_t expected_crc = get_u32le(header + 4);        uint64_t timestamp = get_u64le(header + 8);        if (len > 1024 * 1024) {            fprintf(                stderr,                "invalid record length at record %llu\n",                (unsigned long long)seq            );            break;        }        unsigned char *payload = malloc((size_t)len + 1);        if (!payload)            die("malloc");        result = read_exact(fd, payload, len);        if (result != 1) {            fprintf(                stderr,                "truncated payload at record %llu\n",                (unsigned long long)seq            );            free(payload);            break;        }        uint32_t actual_crc = crc32(payload, len);        if (actual_crc != expected_crc) {            fprintf(                stderr,                "CRC mismatch at record %llu\n",                (unsigned long long)seq            );            free(payload);            break;        }        payload[len] = '\0';        printf(            "%llu\t%llu\t%s\n",            (unsigned long long)seq,            (unsigned long long)timestamp,            payload        );        free(payload);    }    if (close(fd) < 0)        die("close");}int main(int argc, char **argv){    if (argc == 4 && strcmp(argv[1], "add") == 0) {        cmd_add(argv[2], argv[3]);        return 0;    }    if (argc == 3 && strcmp(argv[1], "list") == 0) {        cmd_list(argv[2]);        return 0;    }    fprintf(        stderr,        "usage:\n"        "  %s add  FILE TEXT\n"        "  %s list FILE\n",        argv[0],        argv[0]    );    return 2;}

Compilation is intentionally uneventful:

cc -std=c11 -O2 -Wall -Wextra -pedantic longlog.c -o longlog

Then a few records can be added and read back:

./longlog add history.l30 first-record./longlog add history.l30 second-record./longlog list history.l30

What interests me most about this program is not its size but the list of things it does not contain. There is no JSON parser whose exact behavior becomes part of the persistent format. There is no database engine, runtime environment, plugin system, background worker, reflection layer or dynamically loaded extension.

Even CRC32 is implemented directly in the source file. Normally I would prefer a tested library rather than reimplementing a common algorithm, but this experiment has a different objective. CRC32 is tiny, stable and documented well enough that preserving twenty lines of implementation seems safer than preserving a dependency-resolution mechanism and whatever library ecosystem sits behind it.

At this point the project started feeling strangely old-fashioned. Not necessarily better, just different. Every dependency suddenly had to explain why a programmer in 2056 should still be able to obtain it.

Crash recovery changed the design

A program can remain compilable for thirty years and still be useless if one badly timed crash destroys its data. For that reason I wanted the storage format to survive interrupted writes without requiring any special repair procedure.

The writer appends a header, then the payload, then calls fsync. This does not make a record magically atomic. The process can still die after writing four bytes of the header, or after writing the whole header and half the payload. A machine can also lose power after data reaches the page cache but before it reaches persistent storage.

Trying to remove every possible failure window would require a much more complicated design, so I chose a simpler recovery rule instead: only complete records count.

The reader starts at the beginning and validates each record independently. If it encounters a short header, a short payload or a checksum mismatch, it stops. Every record before that point remains usable.

There is deliberately no global index. There is no footer containing the total number of entries. There is no pointer to the last valid record. All of those structures would improve performance, but they would also introduce another piece of state that could disagree with the data itself.

For a tiny archival format, the file can discover its own valid state simply by being scanned from the beginning.

That does mean that reading ten million records requires scanning ten million records. For a database this would obviously be a problem. For a longevity experiment it is a reasonable trade. Sometimes the most durable index is no index.

I tried breaking every byte boundary

The recovery logic looked correct when reading the code, but that is not enough. File-format bugs often live in the boundaries people assume will never happen.

So I wrote a second program that generates a valid file and then truncates it at every possible byte position. If the valid file contains 5264 bytes, the test produces 5265 different versions, starting with an empty file and ending with the complete one.

For every truncated version, the parser is allowed to return only a valid prefix of complete records. It must never invent a record from incomplete bytes and must never damage records that were already complete before the cut.

#!/usr/bin/env python3import osimport structimport tempfileimport zlibMAGIC = b"L30LOG01"MAX_RECORD = 1024 * 1024def encode_record(ts: int, payload: bytes) -> bytes:    crc = zlib.crc32(payload) & 0xFFFFFFFF    return (        struct.pack("<IIQ", len(payload), crc, ts)        + payload    )def parse_prefix(data: bytes):    if len(data) < len(MAGIC):        return [], "truncated magic"    if data[:8] != MAGIC:        return [], "bad magic"    pos = 8    records = []    while pos < len(data):        if len(data) - pos < 16:            return records, "truncated header"        length, expected_crc, ts = struct.unpack_from(            "<IIQ",            data,            pos        )        pos += 16        if length > MAX_RECORD:            return records, "invalid length"        if len(data) - pos < length:            return records, "truncated payload"        payload = data[pos:pos + length]        pos += length        actual_crc = zlib.crc32(payload) & 0xFFFFFFFF        if actual_crc != expected_crc:            return records, "crc mismatch"        records.append((ts, payload))    return records, "clean eof"def main():    records = [        (1735689600, b"alpha" * 31),        (1735689601, os.urandom(257)),        (1735689602, b"gamma" * 700),        (1735689603, bytes(range(256)) * 5),    ]    original = MAGIC + b"".join(        encode_record(ts, payload)        for ts, payload in records    )    full, status = parse_prefix(original)    assert status == "clean eof"    assert full == records    clean_prefixes = 0    for cut in range(len(original) + 1):        truncated = original[:cut]        parsed, status = parse_prefix(truncated)        assert records[:len(parsed)] == parsed        assert status in {            "truncated magic",            "truncated header",            "truncated payload",            "clean eof",        }        if status == "clean eof":            clean_prefixes += 1    damaged = bytearray(original)    payload_start = 8 + 16    damaged[payload_start + 3] ^= 0x80    parsed, status = parse_prefix(bytes(damaged))    assert parsed == []    assert status == "crc mismatch"    with tempfile.NamedTemporaryFile(delete=False) as f:        f.write(original)        filename = f.name    try:        with open(filename, "rb") as f:            on_disk = f.read()        assert on_disk == original    finally:        os.unlink(filename)    print(        f"tested {len(original) + 1} truncation points"    )    print(        f"clean record boundaries: {clean_prefixes}"    )    print(        "single-bit corruption: detected"    )if __name__ == "__main__":    main()

For this particular test file the script checked 5265 possible truncation positions. It also flipped a single bit inside the first payload to verify that the checksum detected corruption.

The output was:

tested 5265 truncation pointsclean record boundaries: 5single-bit corruption: detected

This test exposed a mistake in an earlier version of the reader. Originally, a short final header was treated as a corrupt file. That sounded reasonable until I realized that a crash during header creation produces exactly the same condition. The writer had not destroyed the file; the reader had simply defined a recoverable state as permanent corruption.

That was one of the more useful moments in the experiment. Durable formats are defined not only by what writers produce but also by what readers are prepared to tolerate.

I stopped trying to support everything

Long-term compatibility creates an easy trap. Once you start worrying about the future, it becomes tempting to support every operating system, every filesystem and every imaginable architecture.

I eventually decided that this would make the program less durable, not more.

The implementation assumes a POSIX-like environment with file descriptors, read, write, lseek and fsync. That is absolutely a dependency. The difference is that it is a small, old and extremely well understood dependency.

The more useful question turned out to be not how many dependencies a project has, but how difficult those dependencies would be to reconstruct.

A dependency on a small POSIX interface is very different from a dependency on one particular package version that itself requires a runtime, a package registry, certificates, build plugins and dozens of transitive modules. Both may look like one dependency when written in a project manifest, but their survival characteristics are completely different.

Static linking also looked attractive at first. One binary containing everything feels like the perfect archival object. The problem is that static linking preserves implementation, not portability. That executable is still tied to a CPU architecture and executable ABI.

Keeping the source simple gives the future machine a chance to rebuild the program for whatever hardware actually exists.

If x86-64 is still common in 2056, fine. If it is not, the source should not particularly care.

Time turned into a surprisingly annoying problem

The program stores each timestamp as an unsigned 64-bit integer containing Unix seconds. That sounds obvious until you try to think thirty years ahead.

Should the timestamp include timezone information? Should the format store calendar dates instead? What about leap seconds? Should timestamps be written as text so humans can read the binary file with basic tools?

Eventually I chose the least ambitious meaning possible. The stored value is simply a Unix timestamp. Converting that timestamp into a human-readable date is presentation logic and does not belong in the persistent format.

The important detail is that the native C time_t value is not written directly to disk. It is converted to uint64_t first. That separation matters. A storage format should not inherit every property of the language implementation used to create it. The same principle applies to integers, enums, booleans, pointers and text encoding. If a format is supposed to survive the program that created it, native in-memory representations are poor documentation.

There is also the famous 2038 problem. A signed 32-bit time_t cannot represent timestamps beyond January 2038. Modern 64-bit Linux environments already avoid that limitation, but the disk format should not depend on the size of time_t anyway.

This creates an interesting distinction: a data format may outlive the environment that was originally capable of creating it. That seems obvious once stated, yet a surprising amount of software serializes native types as if the current machine were a permanent law of nature.

Future-proofing became easier after deleting future features

The first draft of the format was much more complicated. It had reserved bytes, feature flags, capability bits and fields intended for future extensions.

It looked professional. It was also mostly guesswork. After staring at those fields for a while, an uncomfortable question appeared: how could anyone in 2026 know what software in 2046 would need? Most of the reserved space did not represent requirements. It represented anxiety. So I removed almost all of it.

The eight-byte magic value already identifies the format generation. If an incompatible format is ever needed, it can simply become L30LOG02. Version one does not need to understand version two. Version two can contain a small converter for version one.

That is less clever than building an endlessly extensible container format, but it is much easier to document.

There is a common idea that future-proofing means adding flexibility. After this experiment I am less convinced. Every extension mechanism is itself something future software must understand. Feature negotiation needs negotiation rules. Optional fields need semantics. Plugins need interfaces. Schema evolution needs compatibility policies.

Sometimes the format with the fewest future features is the easiest one to preserve.

The future programmer can always invent another format. The important thing is making sure that person can still decode this one.

Documentation may survive longer than the program

Once the source became small enough, another question started bothering me. Suppose somebody actually finds this directory in 2056. Will the code itself be sufficient?

Probably yes, but forcing that person to reverse-engineer the storage format from put_u32le and get_u64le would be unnecessary.

So the format needs its own specification stored as plain text beside the source. Not generated documentation, not a hosted wiki and not an online documentation portal.

Just a text file.

L30LOG format version 1File header:offset  size  meaning0       8     ASCII bytes L30LOG01Record:offset  size  meaning0       4     payload length, unsigned little-endian4       4     CRC32 of payload, little-endian8       8     Unix timestamp in seconds, little-endian16      N     payload bytesMaximum payload:1048576 bytesRecovery:A record is valid only if its complete header and payloadexist and the CRC32 matches.An incomplete final record may be ignored.Readers must stop at the first invalid record.Writers append records and call fsync before reporting success.

That tiny specification may actually have more long-term value than most of the comments in the source code.

Formats often outlive their original implementations. Image formats, archive formats and network protocols can survive through many generations of software because somebody documented what the bytes mean.

Once persistent data matters, the specification stops being secondary documentation. It becomes part of the program.

Things I deliberately refused to add

Once the basic version worked, adding features became surprisingly tempting. Searching records would be useful. Compression would save space. Encryption would protect the data. An index would make reading faster. Multiple concurrent writers would make the utility more practical. Network access would turn it into a tiny service. JSON export would make integration easier.

All of those are reasonable features, and I added none of them. This may have been the hardest part of the experiment.

A feature rarely adds only code. It adds assumptions. Compression introduces an algorithm and probably a library. Encryption introduces key formats, cryptographic algorithms and security expectations that will definitely evolve over thirty years. Networking adds protocols, authentication and certificate handling. Concurrent writers require locking semantics. Indexes introduce consistency rules between two representations of the same data.

Even a JSON library adds parser behavior that may become relevant to compatibility. The project became more durable every time it became less ambitious. Other programs can encrypt the file. Other tools can search it. The operating system can copy it. A separate program can build an index. The log itself does not have to understand any of those tasks. This started to feel very close to old Unix software: one small program, one obvious format, one job. Maybe some old software survived precisely because nobody tried to make every utility responsible for everything.

There are still several things wrong with it

The experiment is deliberately minimal, and there are several limitations that would matter in real software.

The biggest one is concurrent writing. Two processes can open the same file, seek to the end and then interleave a header and payload. The current implementation should therefore be treated as single-writer. A production version would need file locking or a different append strategy.

There is also a durability detail around initial file creation. Calling fsync on the file helps make its contents durable, but creating a new file modifies directory metadata too. If the requirement includes surviving sudden power failure immediately after the file is created, the containing directory should also be synchronized.

Text encoding is another loose end. The command-line interface makes the payload look like text, but the storage layer really stores bytes. A serious specification should either say that payloads are arbitrary binary data or define UTF-8 explicitly.

Corruption recovery could also be improved. The reader stops at the first damaged record. That is conservative and simple, but it means one bad record hides valid data after it. A more advanced format could include resynchronization markers or provide a separate recovery utility that scans for plausible record boundaries.

I would also preserve several known-good binary files with expected decoded contents. These golden test vectors could be tiny, but they would give a future reimplementation something concrete to verify against. A format specification explains what should happen; test vectors prove that two implementations agree about what those words mean.

What I would not do is solve all of these issues by introducing a large framework. None of them requires that.

Could it really run in 2056?

There is no honest way to prove thirty-year compatibility today. A future compiler could reject something current compilers accept. POSIX could become irrelevant. Ordinary machines could use hardware models that make current assumptions look strange.

What can be tested is the number of assumptions the project makes. The final program assumes an ordinary C11 compiler, eight-bit bytes, fixed-width integer support, a POSIX-like file interface, persistent files and enough memory to hold a single record.

That is still a dependency list. Now compare it with an ordinary modern web application. It may assume a specific runtime, a package manager, an online registry, hundreds of exact dependency versions, a database protocol, a container runtime, a build system, framework behavior, certificate infrastructure, authentication services, cloud APIs and several pieces of configuration machinery.

Suddenly a handful of boring assumptions looks much less primitive. This also changed the way I think about old software. Code does not become old merely because thirty years pass. Code becomes old when the environment required to understand or rebuild it disappears.

A program from 1996 that expects a C compiler may be easier to recover than a program from 2022 that expects an abandoned package registry and hundreds of exact packages. That is probably the most uncomfortable result of the whole experiment.

The part I did not expect

I started this expecting to write something mostly about C. In the end, the language was almost the least interesting part.

The file format mattered more. The build process mattered more. The distinction between native memory representation and persistent representation mattered more. The amount of external infrastructure required to reconstruct the program mattered much more.

The biggest lesson was restraint. There is a simple thought experiment that can be applied to almost any repository. Imagine copying the whole project onto a disk today. Then imagine that every external service used by the project disappears. Thirty years later, somebody receives that disk together with a current operating system and a compiler.

How much of the project is actually there? For many modern applications, the repository is not really the complete source. It is partly a list of coordinates pointing toward code stored elsewhere. That is perfectly reasonable for software designed to live for a few years and receive constant maintenance. It becomes less comfortable when the program controls data or logic that may need to survive organizations, teams and technology cycles. Would I write every new project as one C file after this? Definitely not. That would be the wrong conclusion. Most software benefits enormously from libraries, frameworks and modern tooling.

But there is a class of software where boring technology has an underestimated advantage: recovery tools, archival utilities, bootstrapping code, persistent file formats, migration tools and infrastructure expected to live much longer than the team that created it.

Boring things are easier to rediscover. Thirty years from now, nobody needs to admire this program. Nobody even needs to enjoy working with it. They only need to understand what it does and make it run again. For long-lived software, that may be the more useful definition of success.

ссылка на оригинал статьи https://habr.com/ru/articles/1077938/