From d171b53520de58757727896e4c9a5cdaf5558ad0 Mon Sep 17 00:00:00 2001 From: tannevaled Date: Tue, 25 Aug 2026 17:45:23 +0200 Subject: [PATCH] Compress, encrypt, decrypt, and say what a file allows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four verbs the reader has been able to support since it learned to encrypt on writing, and which nothing was offering. Compress packs the objects into compressed streams and a cross-reference stream, which is what every writer since 2003 does. Over the whole corpus: 118 833 of 118 833 files come out with the same pages, 118 746 of them smaller, 35.2 GB down to 33.6 GB — 95.4% of what they were. Encrypt protects the file that will be written, with either the current method or the one readers from before 2008 understand. Decrypt writes it back out without protection. Protection says how the file that was read was protected, which is what the new permissions verb prints: the method in words, which password opened it, and what it allows. The whole corpus goes through all three: 118 833 files compressed and read back with the pages byte for byte the same, 118 833 encrypted and refused without a password and opened by both of them, 118 833 decrypted back to the pages they started as. Nothing failed and nothing panicked. --- cmd/pdfops/run.go | 153 ++++++++++++++++++++++++++++++++++++ cmd/pdfops/run_test.go | 107 ++++++++++++++++++++++++++ doc.go | 5 ++ go.mod | 2 +- go.sum | 6 +- protect.go | 38 +++++++++ protect_test.go | 171 +++++++++++++++++++++++++++++++++++++++++ write.go | 8 ++ 8 files changed, 485 insertions(+), 5 deletions(-) create mode 100644 protect.go create mode 100644 protect_test.go diff --git a/cmd/pdfops/run.go b/cmd/pdfops/run.go index 05f1fb0..40bda93 100644 --- a/cmd/pdfops/run.go +++ b/cmd/pdfops/run.go @@ -51,6 +51,10 @@ var commands = []command{ {"strip", "[-annotations] [-bookmarks] ", "write the file without its metadata", runStrip}, {"sanitize", " ", "remove what runs rather than shows: scripts, launching, embedded files", runSanitize}, {"flatten", " ", "draw the annotations into the page and drop them", runFlatten}, + {"compress", " ", "pack the objects into compressed streams", runCompress}, + {"encrypt", "-user [-owner ] [-allow ] [-aes128] ", "protect the file with a password", runEncrypt}, + {"decrypt", " ", "write the file without its protection", runDecrypt}, + {"permissions", "", "say how the file is protected and what it allows", runPermissions}, } // run is the whole program, so that the tests can drive it. @@ -625,3 +629,152 @@ func runStamp(c *context, args []string) error { } return save(d, fs.Arg(1)) } + +// runCompress packs the file's objects into compressed streams. +func runCompress(c *context, args []string) error { + fs := flags("compress") + if err := fs.Parse(args); err != nil { + return err + } + if err := wantArgs(fs, 2, " "); err != nil { + return err + } + d, err := c.open(fs.Arg(0)) + if err != nil { + return err + } + d.Compress() + return save(d, fs.Arg(1)) +} + +// permissionFlags names each permission the way a person would ask for it. +var permissionFlags = []struct { + name string + bit reader.Permissions +}{ + {"print", reader.PermPrint}, + {"print-faithful", reader.PermPrintFaithful}, + {"modify", reader.PermModify}, + {"assemble", reader.PermAssemble}, + {"copy", reader.PermCopy}, + {"extract", reader.PermExtract}, + {"annotate", reader.PermAnnotate}, + {"fill-forms", reader.PermFillForms}, + {"all", reader.AllPermissions}, + {"none", 0}, +} + +// parsePermissions reads a comma-separated list of what a reader may do. +func parsePermissions(spec string) (reader.Permissions, error) { + if spec == "" { + return reader.AllPermissions, nil + } + var out reader.Permissions + for _, word := range strings.Split(spec, ",") { + word = strings.TrimSpace(word) + found := false + for _, p := range permissionFlags { + if p.name == word { + out |= p.bit + found = true + break + } + } + if !found { + return 0, fmt.Errorf("no such permission %q; there is %s", word, permissionList()) + } + } + return out, nil +} + +// permissionList is every permission name, for an error message. +func permissionList() string { + names := make([]string, 0, len(permissionFlags)) + for _, p := range permissionFlags { + names = append(names, p.name) + } + return strings.Join(names, ", ") +} + +// runEncrypt protects a file with a password. +func runEncrypt(c *context, args []string) error { + fs := flags("encrypt") + user := fs.String("user", "", "the password that opens the file, subject to the permissions") + owner := fs.String("owner", "", "the password that opens it subject to nothing") + allow := fs.String("allow", "", "what a reader may do: "+permissionList()) + aes128 := fs.Bool("aes128", false, "use the older 128-bit method, for readers from before 2008") + if err := fs.Parse(args); err != nil { + return err + } + if err := wantArgs(fs, 2, " "); err != nil { + return err + } + if *user == "" && *owner == "" { + return fmt.Errorf("encrypt needs a -user or an -owner password") + } + perms, err := parsePermissions(*allow) + if err != nil { + return err + } + d, err := c.open(fs.Arg(0)) + if err != nil { + return err + } + d.Encrypt(reader.Encryption{ + UserPassword: *user, + OwnerPassword: *owner, + Permissions: perms, + AES128: *aes128, + }) + return save(d, fs.Arg(1)) +} + +// runDecrypt writes the file without its protection. The password to open it +// with is the global -password. +func runDecrypt(c *context, args []string) error { + fs := flags("decrypt") + if err := fs.Parse(args); err != nil { + return err + } + if err := wantArgs(fs, 2, " "); err != nil { + return err + } + d, err := c.open(fs.Arg(0)) + if err != nil { + return err + } + d.Decrypt() + return save(d, fs.Arg(1)) +} + +// runPermissions says how a file is protected and what it allows. +func runPermissions(c *context, args []string) error { + fs := flags("permissions") + if err := fs.Parse(args); err != nil { + return err + } + if err := wantArgs(fs, 1, ""); err != nil { + return err + } + d, err := c.open(fs.Arg(0)) + if err != nil { + return err + } + p, ok := d.Protection() + if !ok { + fmt.Fprintln(c.out, "protection none") + return nil + } + fmt.Fprintf(c.out, "protection %s, revision %d\n", p.Method, p.Revision) + fmt.Fprintf(c.out, "opened as %s\n", openedAs(p.Owner)) + fmt.Fprintf(c.out, "allows %s\n", p.Permissions) + return nil +} + +// openedAs says which password the file was opened with. +func openedAs(owner bool) string { + if owner { + return "the owner, so the permissions do not apply" + } + return "the user" +} diff --git a/cmd/pdfops/run_test.go b/cmd/pdfops/run_test.go index 111cf2c..5789082 100644 --- a/cmd/pdfops/run_test.go +++ b/cmd/pdfops/run_test.go @@ -529,3 +529,110 @@ func TestSanitizeFlattenAndStripFlags(t *testing.T) { } } } + +func TestCompressVerb(t *testing.T) { + in := fixture(t, 30) + out := filepath.Join(t.TempDir(), "packed.pdf") + if code, _, errOut := exec("compress", in, out); code != 0 { + t.Fatalf("compress said %d: %s", code, errOut) + } + before, _ := os.Stat(in) + after, _ := os.Stat(out) + if after.Size() >= before.Size() { + t.Errorf("packed is %d bytes and plain is %d", after.Size(), before.Size()) + } + if got := contentsOf(t, out); len(got) != 30 || got[0] != "page 1" { + t.Errorf("the packed file reads back as %v", got) + } + if code, _, _ := exec("compress", in); code == 0 { + t.Error("compress with one path was accepted") + } + if code, _, _ := exec("compress", "nowhere.pdf", out); code == 0 { + t.Error("compress read a file that is not there") + } + if code, _, _ := exec("compress", "-nope", in, out); code == 0 { + t.Error("compress took a flag it has never heard of") + } +} + +func TestEncryptDecryptAndPermissionsVerbs(t *testing.T) { + in := fixture(t, 3) + dir := t.TempDir() + locked := filepath.Join(dir, "locked.pdf") + if code, _, errOut := exec("encrypt", "-user", "u", "-owner", "o", "-allow", "print,copy", in, locked); code != 0 { + t.Fatalf("encrypt said %d: %s", code, errOut) + } + b, err := os.ReadFile(locked) + if err != nil { + t.Fatal(err) + } + if _, err := reader.Open(b); err == nil { + t.Error("the encrypted file opened with no password") + } + + code, out, errOut := exec("-password", "u", "permissions", locked) + if code != 0 { + t.Fatalf("permissions said %d: %s", code, errOut) + } + for _, want := range []string{"AES-256", "the user", "print, copy"} { + if !strings.Contains(out, want) { + t.Errorf("permissions did not mention %q:\n%s", want, out) + } + } + if _, out, _ = exec("-password", "o", "permissions", locked); !strings.Contains(out, "the owner") { + t.Errorf("the owner password was not recognised:\n%s", out) + } + if _, out, _ = exec("permissions", in); !strings.Contains(out, "protection none") { + t.Errorf("an unprotected file reported:\n%s", out) + } + + plain := filepath.Join(dir, "plain.pdf") + if code, _, errOut := exec("-password", "u", "decrypt", locked, plain); code != 0 { + t.Fatalf("decrypt said %d: %s", code, errOut) + } + if got := contentsOf(t, plain); len(got) != 3 || got[2] != "page 3" { + t.Errorf("the decrypted file reads back as %v", got) + } + + // The older method, for readers from before 2008. + old := filepath.Join(dir, "old.pdf") + if code, _, errOut := exec("encrypt", "-user", "u", "-aes128", in, old); code != 0 { + t.Fatalf("encrypt -aes128 said %d: %s", code, errOut) + } + if _, out, _ = exec("-password", "u", "permissions", old); !strings.Contains(out, "AES-128") { + t.Errorf("-aes128 produced:\n%s", out) + } +} + +func TestTheVerbsThatProtectRefuseWhatTheyShould(t *testing.T) { + in := fixture(t, 2) + out := filepath.Join(t.TempDir(), "out.pdf") + cases := []struct { + name string + args []string + }{ + {"encrypt with no password at all", []string{"encrypt", in, out}}, + {"encrypt with a permission nobody has heard of", []string{"encrypt", "-user", "u", "-allow", "fly", in, out}}, + {"encrypt with one path", []string{"encrypt", "-user", "u", in}}, + {"encrypt a file that is not there", []string{"encrypt", "-user", "u", "nowhere.pdf", out}}, + {"encrypt with an unknown flag", []string{"encrypt", "-nope", in, out}}, + {"decrypt with one path", []string{"decrypt", in}}, + {"decrypt a file that is not there", []string{"decrypt", "nowhere.pdf", out}}, + {"decrypt with an unknown flag", []string{"decrypt", "-nope", in, out}}, + {"permissions with no path", []string{"permissions"}}, + {"permissions on a file that is not there", []string{"permissions", "nowhere.pdf"}}, + {"permissions with an unknown flag", []string{"permissions", "-nope", in}}, + } + for _, c := range cases { + if code, _, _ := exec(c.args...); code == 0 { + t.Errorf("%s was accepted", c.name) + } + } + // Asking for everything is what saying nothing means, and "none" is a + // way of saying it out loud. + for _, allow := range []string{"", "all", "none", "print, copy"} { + if code, _, errOut := exec("encrypt", "-user", "u", "-allow", allow, in, out); code != 0 { + t.Errorf("-allow %q said %d: %s", allow, code, errOut) + } + } +} diff --git a/doc.go b/doc.go index 697298b..ddae8ae 100644 --- a/doc.go +++ b/doc.go @@ -27,6 +27,11 @@ type Doc struct { flatten bool dropAnnots bool dropOutlines bool + + // How the file is written: packed into compressed object streams, and + // protected or not. + packed bool + protect *reader.Encryption } // A Page is one page of a document, borrowed from the file it came from. The diff --git a/go.mod b/go.mod index 6901277..5224f40 100644 --- a/go.mod +++ b/go.mod @@ -2,4 +2,4 @@ module github.com/go-pdfkit/ops go 1.26.4 -require github.com/go-pdfkit/reader v0.2.0 // indirect +require github.com/go-pdfkit/reader v0.4.0 diff --git a/go.sum b/go.sum index 38a6b2f..3ff081a 100644 --- a/go.sum +++ b/go.sum @@ -1,4 +1,2 @@ -github.com/go-pdfkit/reader v0.1.0 h1:DbkTAXYWdhd6C5yl7+Ki68QWlg+h7vfLQw79q94qerw= -github.com/go-pdfkit/reader v0.1.0/go.mod h1:fQFOVfCMUui1AdvD4qhimdyvvNr9KvvJ1S7IuKZjyV8= -github.com/go-pdfkit/reader v0.2.0 h1:jY8BZkfnHw/ImyRANapU/lQrTM1d1hR960Xb9nGC+nY= -github.com/go-pdfkit/reader v0.2.0/go.mod h1:fQFOVfCMUui1AdvD4qhimdyvvNr9KvvJ1S7IuKZjyV8= +github.com/go-pdfkit/reader v0.4.0 h1:qPbNZSO+Xl+4NBvQoV1PYt7HlqHaEAQ1tUc6/IL8JAU= +github.com/go-pdfkit/reader v0.4.0/go.mod h1:fQFOVfCMUui1AdvD4qhimdyvvNr9KvvJ1S7IuKZjyV8= diff --git a/protect.go b/protect.go new file mode 100644 index 0000000..597c1a4 --- /dev/null +++ b/protect.go @@ -0,0 +1,38 @@ +package ops + +import "github.com/go-pdfkit/reader" + +// Compress asks for the file to be written with its objects packed into +// compressed streams and a cross-reference stream, which is what every writer +// since PDF 1.5 does and what makes a file a good deal smaller. It costs +// nothing but a version of 1.5, which every reader in use has understood for +// twenty years. +func (d *Doc) Compress() { d.packed = true } + +// Encrypt protects the file that will be written. Two people can open it: +// whoever knows the user password, subject to the permissions, and whoever +// knows the owner password, subject to nothing. +// +// An encrypted file is not byte-for-byte reproducible — encryption needs +// randomness, by design — so a document written twice with the same call comes +// out different both times, and neither can be compared with the other. +func (d *Doc) Encrypt(e reader.Encryption) { d.protect = &e } + +// Decrypt writes the file without protection. A document opened with the right +// password is already decrypted, so this only undoes an earlier call to +// [Doc.Encrypt]; a file read with [OpenWithPassword] and written out is +// unprotected either way. +func (d *Doc) Decrypt() { d.protect = nil } + +// Protection reports how the file this document was read from was protected, +// and false when it was not protected at all — or when the document was not +// read from a file. It says nothing about how the document will be written: +// that is what was passed to [Doc.Encrypt]. +func (d *Doc) Protection() (reader.Protection, bool) { + for _, p := range d.pages { + if p.src != nil { + return p.src.Protection() + } + } + return reader.Protection{}, false +} diff --git a/protect_test.go b/protect_test.go new file mode 100644 index 0000000..f90734f --- /dev/null +++ b/protect_test.go @@ -0,0 +1,171 @@ +package ops + +import ( + "errors" + "testing" + + "github.com/go-pdfkit/reader" +) + +// bulky is a document whose pages carry enough repetition to be worth +// compressing, which a handful of bytes would not be. +func bulky(t *testing.T, n int) []byte { + t.Helper() + return buildPDF(t, n, func(i int, d reader.Dict) { + d["Keywords"] = reader.String("the same long string on every single page, over and over") + }) +} + +// opened reads a file the test has built, or fails. +func opened(t *testing.T, b []byte) *Doc { + t.Helper() + d, err := Open(b) + if err != nil { + t.Fatal(err) + } + return d +} + +func TestCompressingAFile(t *testing.T) { + // Packing the objects into compressed streams has to leave the pages + // exactly as they were and the file smaller than it was. + src := bulky(t, 40) + plain, err := opened(t, src).Bytes() + if err != nil { + t.Fatal(err) + } + d := opened(t, src) + d.Compress() + packed, err := d.Bytes() + if err != nil { + t.Fatal(err) + } + if len(packed) >= len(plain) { + t.Errorf("packed is %d bytes and plain is %d", len(packed), len(plain)) + } + if got, want := contentsOf(t, packed), contentsOf(t, plain); !equal(got, want) { + t.Error("packing changed what is on the pages") + } + // A packed file needs a version that says so. + back, err := reader.Open(packed) + if err != nil { + t.Fatal(err) + } + if back.Version() < "1.5" { + t.Errorf("a packed file says it is version %s", back.Version()) + } +} + +func TestEncryptingAFile(t *testing.T) { + // Both passwords open it and nothing else does; what the pages say is + // unchanged; and the permissions written are the ones asked for. + for _, packed := range []bool{false, true} { + d := opened(t, simple(t, 3)) + if packed { + d.Compress() + } + d.Encrypt(reader.Encryption{ + UserPassword: "letmein", OwnerPassword: "iownit", + Permissions: reader.PermPrint | reader.PermExtract, + }) + out, err := d.Bytes() + if err != nil { + t.Fatal(err) + } + if _, err := reader.Open(out); !errors.Is(err, reader.ErrWrongPassword) { + t.Errorf("packed=%v: an encrypted file opened with no password: %v", packed, err) + } + if _, err := reader.OpenWithPassword(out, "guess"); !errors.Is(err, reader.ErrWrongPassword) { + t.Errorf("packed=%v: a wrong password opened it", packed) + } + for _, pw := range []string{"letmein", "iownit"} { + back, err := reader.OpenWithPassword(out, pw) + if err != nil { + t.Fatalf("packed=%v: %q did not open it: %v", packed, pw, err) + } + if back.PageCount() != 3 { + t.Errorf("packed=%v: %q sees %d pages", packed, pw, back.PageCount()) + } + p, ok := back.Protection() + if !ok { + t.Fatalf("packed=%v: it says it is not protected", packed) + } + if p.Permissions != reader.PermPrint|reader.PermExtract { + t.Errorf("packed=%v: it allows %v", packed, p.Permissions) + } + if want := pw == "iownit"; p.Owner != want { + t.Errorf("packed=%v: %q opened as owner = %v", packed, pw, p.Owner) + } + } + } +} + +func TestDecryptingAFile(t *testing.T) { + // A file read with its password and written out again is not protected, + // and Decrypt takes back an Encrypt that has not happened yet. + d := opened(t, simple(t, 2)) + d.Encrypt(reader.Encryption{UserPassword: "shh"}) + locked, err := d.Bytes() + if err != nil { + t.Fatal(err) + } + back, err := OpenWithPassword(locked, "shh") + if err != nil { + t.Fatal(err) + } + plain, err := back.Bytes() + if err != nil { + t.Fatal(err) + } + if _, err := reader.Open(plain); err != nil { + t.Errorf("a file written from a decrypted document still needs a password: %v", err) + } + if got := contentsOf(t, plain); !equal(got, pages(1, 2)) { + t.Errorf("the pages came out as %v", got) + } + + d2 := opened(t, simple(t, 1)) + d2.Encrypt(reader.Encryption{UserPassword: "shh"}) + d2.Decrypt() + out, err := d2.Bytes() + if err != nil { + t.Fatal(err) + } + if _, err := reader.Open(out); err != nil { + t.Errorf("Decrypt did not take back Encrypt: %v", err) + } +} + +func TestWhatADocumentSaysItWasProtectedWith(t *testing.T) { + // It reports the file it was read from, not what it will be written as. + d := opened(t, simple(t, 1)) + d.Encrypt(reader.Encryption{UserPassword: "shh", Permissions: reader.PermPrint}) + locked, err := d.Bytes() + if err != nil { + t.Fatal(err) + } + if p, ok := d.Protection(); ok { + t.Errorf("a document read from an unprotected file reported %+v", p) + } + + back, err := OpenWithPassword(locked, "shh") + if err != nil { + t.Fatal(err) + } + p, ok := back.Protection() + if !ok { + t.Fatal("a document read from a protected file says it was not") + } + if p.Method != "AES-256" || p.Permissions != reader.PermPrint { + t.Errorf("it reports %+v", p) + } + + // A document with no page borrowed from anywhere has nothing to report. + blank := &Doc{version: "1.7"} + if err := blank.InsertBlank(1); err != nil { + t.Fatal(err) + } + if p, ok := blank.Protection(); ok { + t.Errorf("a document made from nothing reported %+v", p) + } +} diff --git a/write.go b/write.go index db00b3f..5c53579 100644 --- a/write.go +++ b/write.go @@ -26,6 +26,14 @@ func (d *Doc) Bytes() ([]byte, error) { return nil, fmt.Errorf("ops: a document with no pages cannot be written") } w := reader.NewWriter(d.version) + if d.packed { + w = reader.NewPackedWriter(d.version) + } + if d.protect != nil { + // Before anything is written: a file cannot be protected after the + // fact, because the key is what everything in it is written through. + w.Encrypt(*d.protect) + } pagesRef := w.Reserve() // Pages are numbered first and written last: what goes on one of them may